A Chrome extension and CLI that let your agents drive your actual browser — with logins, extensions and cookies already there. Star on GitHub.
Other browser tools spawn a fresh Chrome: logged into nothing, extensions gone, flagged by bot detection on sight. Others extract your cookies to a file and replay them, which works until a site checks anything beyond a cookie. RunBrowser does neither. Your code runs inside the page, on the site's own origin, so the browser attaches your session itself. No credential is ever extracted or stored — there is nothing for this tool to leak, because it never holds one.
Getting started
Install the CLI. One binary, self-contained — no Node, Bun or npm involved.
curl -fsSL https://runbrowser.com/install | sh
Install the extension, then click its icon on a tab to attach it. The icon turns green.
Check it worked.
runbrowser status runbrowser eval 'document.title'
How it works
Three pieces, and the browser is never one you launched.
The extension attaches to a tab you choose, via chrome.debugger. The relay runs on your machine and speaks the Chrome DevTools Protocol to that extension over a local WebSocket. The CLI talks to the relay.
Nothing leaves your machine. Nothing is stored. The tab is one you picked, and Chrome shows its own "is being debugged" banner the whole time — which RunBrowser never suppresses.
The commands
Everything a page can do is a CDP method, so cdp reaches the entire protocol. There is no click, no snapshot, no element-handle system — a wrapper per action is one more thing to get wrong, and Chrome's protocol is complete, documented, and versioned by Chrome.
runbrowser cdp <Method> [params-json] # the whole protocol runbrowser eval '<js>' # JavaScript in the page runbrowser exec # a snippet with helpers, from stdin runbrowser tab list|new|<index>|close # which target you are bound to runbrowser session new|list|delete # isolated state, one per agent runbrowser plugin list|install # site plugins runbrowser mcp # MCP server on stdio runbrowser serve # the relay, for remote access
cdp prints JSON, so pipe it:
runbrowser cdp Accessibility.getFullAXTree \ | jq '.nodes[] | select(.role.value=="button") | .name.value'
eval runs in the same mode the DevTools console does — the value is the last expression, and top-level await works:
runbrowser eval 'document.title' runbrowser eval 'const r = await fetch("/api/me"); (await r.json()).name'
exec
cdp and eval are one call each. When a task needs a loop, a condition, or a wait built from what actually happened, use exec — a snippet with helpers already in scope.
runbrowser exec <<'JS' await cdp('Page.navigate', { url: 'https://example.com' }) await waitFor(async () => (await evaluate('document.readyState')) === 'complete') const links = await evaluate('[...document.querySelectorAll("a")].map(a => a.href)') return { title: await evaluate('document.title'), links } JS
In scope: cdp, evaluate, pageInfo, tabs, newTab, switchTab, closeTab, drainEvents, setEventFilter, wait, waitFor.
Anything you export from ~/.runbrowser/workspace/helpers.ts joins that scope and reloads when you edit it — so when you work out a sequence that works, you keep it instead of deriving it again next time.
Events
CDP is commands and events. Commands return results; events are buffered per session and drained when you want them. That is what lets you wait on things with no observable side effect to poll for — dialogs, downloads, popups, target attachment.
runbrowser exec <<'JS' await setEventFilter('^Page\\.') await drainEvents() await cdp('Page.navigate', { url: 'https://example.com' }) const { events, dropped } = await drainEvents() return events.map(e => e.method) JS
The buffer is capped and reports what it dropped, so a busy page cannot grow it without limit — set a filter rather than letting Network.* flood it.
Plugins
144 plugins across 50 sites ship with RunBrowser — reddit, twitter, zhihu, weibo, bilibili, v2ex, github, hackernews, linkedin, xiaohongshu and more.
runbrowser plugin list runbrowser plugin install v2ex runbrowser v2ex hot --count 5
A plugin is a JSON header and a bare async function, evaluated in the page — one round trip, with that site's cookies, origin and its own JavaScript available:
/* @meta { "name": "v2ex/hot", "domain": "www.v2ex.com", "args": { "count": { "type": "number", "description": "How many" } } } */ async function(args) { const resp = await fetch('/api/topics/hot.json', { credentials: 'include' }) const topics = await resp.json() return topics.slice(0, args.count || 20).map((t, i) => ({ rank: i + 1, title: t.title, replies: t.replies, })) }
domain is the load-bearing field: it decides which origin the function runs on, and therefore whose cookies it gets. Install from anyone's repository:
runbrowser plugin install <site> --repo owner/name
Writing a plugin
A plugin is one file. Drop it in ~/.runbrowser/plugins/<site>/<name>.js and it becomes runbrowser <site> <name> immediately — no build step, no registration.
1. Find the request the site already makes. Open the page, watch the Network tab, and look for the JSON endpoint its own frontend calls. That endpoint is almost always better than scraping markup: class names change every deploy, an API the site depends on does not.
2. Write the header and the function.
/* @meta { "name": "hackernews/top", "description": "Hacker News front page", "domain": "news.ycombinator.com", "args": { "limit": { "type": "number", "description": "How many stories" } }, "columns": ["rank", "title", "points"] } */ async function(args) { const limit = args.limit || 20 return [...document.querySelectorAll('tr.athing')].slice(0, limit).map((tr, i) => ({ rank: i + 1, title: tr.querySelector('.titleline a')?.textContent ?? '', points: tr.nextElementSibling?.querySelector('.score')?.textContent ?? '', })) }
The function body runs in the page, so document, fetch and the site's own JavaScript are all available. It is a bare async function, not a module — no imports, no exports.
3. Run it.
runbrowser hackernews top --limit 5 runbrowser hackernews top --limit 5 --json # the raw value
What actually goes wrong
Absolute URLs. fetch('/api/items') after navigating to the site is same-origin and carries the user's cookies. fetch('https://other-host/api') is not, and the browser will refuse it — this is the single most common reason a plugin returns nothing.
Forgetting credentials: 'include'. Without it, fetch sends no cookies and you get the logged-out version of the page.
Returning nothing useful. Return an array of flat objects and the table renders itself. An envelope like { count, items } also works — the array inside is found — but flat rows are easier to read.
Guessing instead of looking. Use runbrowser exec to poke at the page first, then move what worked into a plugin. That is the whole loop: work it out once, write it down, never derive it again.
Sharing it
Put the file in a GitHub repository under <site>/<name>.js and anyone can install it:
runbrowser plugin install <site> --repo you/your-repo
A leading underscore (_helper.js) marks a file that is not itself a command — shared code a site's plugins keep beside them.
Sessions
A session is isolated state bound to one tab. Tabs are shared; state is not, so several agents can work in the same browser without stepping on each other.
runbrowser session new # → prints an id runbrowser -s 3 tab list # act inside session 3
Without -s, the CLI reuses an existing session or creates one.
MCP
The MCP server is a verb on the same binary, not a separate package:
{ "mcpServers": { "runbrowser": { "command": "runbrowser", "args": ["mcp"] } } }
For agents that read skills rather than tool schemas:
runbrowser skill install # → ./.claude/skills and ./.agents/skills
Remote access
The browser lives where you are; the agent can live elsewhere. Run the relay bound to an interface the agent can reach, with a token:
runbrowser serve --host 0.0.0.0 --token <secret>
Then point the CLI at it:
RUNBROWSER_HOST=my-machine RUNBROWSER_TOKEN=<secret> runbrowser status
A browser on a server is worthless — no cookies, no SSO, no 2FA. The useful arrangement is the opposite: the browser stays with the human, and the agent reaches back to it.
Comparison
| Playwright / Puppeteer | RunBrowser | |
|---|---|---|
| Browser | Fresh, headless | Yours, already running |
| Login state | None — log in every time | Already there |
| Extensions | None | The ones you use |
| Anti-bot | Detected on sight | It is a real browser |
| Request signing | Reimplement it yourself | The page does it |
| Needs a live browser | No | Yes |
| Cookie extraction | RunBrowser | |
|---|---|---|
| Where the session lives | Copied to disk, encrypted | Stays in Chrome |
| Credential stored | Yours to secure and refresh | None. Nothing is extracted |
| Staleness | Expires — needs a refresh loop | The page keeps it fresh |
| Fingerprinting, TLS checks | Fails them | It is Chrome |
| Token refresh the page performs | Breaks | Happens normally |
| Works headless on a server | Yes | No |
If a site has a real API and you want long-running headless jobs, use a credential-injection tool — that is a different problem, honestly solved elsewhere. RunBrowser is for the sites that have no API and whose frontend does the signing.
Security
No credential is ever extracted or stored. The relay has no cookie access — no getCookies, no cookie jar, nothing on disk. Plugin code runs inside the page, so the browser attaches your session itself, over its own TLS stack.
The relay binds to localhost by default, and the extension endpoint accepts connections only from a known extension origin on the local machine. Remote access is opt-in, requires a token, and refuses cross-origin browser requests outright.
Chrome's own banner stays up. The "is being debugged" notice is never suppressed — if something is driving your browser, you can see it.
One tab, chosen by you. The extension attaches to the tab whose icon you clicked, not to your whole profile.