Hubcap Bridge: Two-Way Communication with Browser JavaScript

This is a follow-up to Introducing Hubcap.

Hubcap started as a way for AI agents to control Chrome. Each command is a one-shot operation: navigate here, click that, screenshot this. But some problems need a persistent connection. Today's release adds bridge, a command that keeps a two-way message channel open between your terminal and JavaScript running in the browser.

The problem

Many web apps expose rich client-side JavaScript APIs. A trading platform has methods to place orders and stream prices. A project management tool has an API to create and update tasks. A design tool lets you manipulate the canvas programmatically. If an AI agent could call these APIs directly, it could do far more than click buttons and scrape text.

I've been building integrations that do exactly this. A server runs locally, JavaScript injected into the page connects to it (usually via WebSocket), and data flows both ways. The local side might be syncing with a database or an LLM might be driving the interaction, querying a full dataset locally and pushing updates to the page through its own API. The page becomes an interface the agent operates through, not just a surface it reads.

This works, but browser security can get in the way. CORS blocks cross-origin requests. Mixed content policies block HTTP from HTTPS pages. Content Security Policy blocks WebSocket connections to unexpected hosts. You end up fighting the browser instead of building the integration.

CDP bypasses all of this. Code injected via the DevTools Protocol runs in the page's context with full access to the page's APIs, no origin restrictions, no CSP issues. But until now, Hubcap's eval command was one-shot: evaluate an expression, get a result, done. There was no way to keep a channel open.

How bridge works

The bridge command takes a JavaScript script and runs it in the page. The script gets two things in scope: send() to send messages out, and messages, an async iterator to receive messages in.

hubcap bridge --target "$TAB" '
  for await (const msg of messages) {
    const result = await window.appAPI.query(msg.sql);
    send({rows: result});
  }
'

On the CLI side, the bridge uses LDJSON (line-delimited JSON) over stdio. Messages from the browser appear on stdout. Messages to the browser go on stdin:

# stdout (from browser)
{"type":"ready"}
{"type":"message","data":{"rows":[...]}}
{"type":"closed","data":"script ended"}

# stdin (to browser)
{"data":{"sql":"SELECT * FROM users"}}
{"type":"close"}

Every event has a type. ready means the bridge is up. message carries data from send(). error reports uncaught exceptions. closed means it's over.

Keepalive

A persistent connection needs to handle disconnection. If the hubcap process gets killed, the JavaScript shouldn't spin forever waiting for messages that will never arrive.

The bridge sends a heartbeat every two seconds. The JS side has a watchdog that closes the async iterator if no heartbeat arrives for six seconds. The script's for await loop exits cleanly, any cleanup runs, and the page goes back to normal.

Going the other direction, if the tab navigates away or crashes, CDP events tell hubcap immediately.

Instance isolation

Each bridge gets a random ID. All the injected globals (the send binding, the push function, the heartbeat handler) are namespaced under that ID. Multiple bridges can run in the same tab without interfering with each other. The script never sees the plumbing; it just gets send and messages.

Examples

Sync with a page API

# sync.js
for await (const msg of messages) {
  if (msg.action === "get") {
    const data = await window.appAPI.getData(msg.key);
    send({key: msg.key, value: data});
  } else if (msg.action === "set") {
    await window.appAPI.setData(msg.key, msg.value);
    send({key: msg.key, ok: true});
  }
}

# Run it
hubcap bridge --target "$TAB" --file sync.js

Pipe data through a page

# Send a message, get the echo
echo '{"data":{"n":7}}' | hubcap bridge '
  for await (const msg of messages) {
    send({doubled: msg.n * 2});
    break;
  }
'

Read page state on demand

hubcap bridge 'send({
  url: location.href,
  title: document.title,
  cookies: document.cookie
})'

Also in this release

eval and run now support top-level await. You can write await fetch('/api/data') directly instead of wrapping in an async IIFE or chaining with .then(). This makes one-shot async operations much cleaner.

Get it

brew upgrade hubcap
# or
brew install tomyan/tap/hubcap

Links