The browser extension
You are reading a PR on GitHub and something is wrong. Today, acting on it means leaving the page, opening Zimmer, working out which session produced the thing — you may have no idea — and typing into the right place. The thread you were on is gone by the time you are done.
The browser extension is Zimmer’s Quick Router bubble freed from Zimmer’s own origin and given a pin. On any page: press a shortcut, type, send — pointing at the thing your feedback is about first, if it is about one thing. A router session starts with your words, the page’s URL and content, and the spot you pinned, if you pinned one. You never leave the page and never look for the session.
It lives in browser-extension/
in the repo — Chrome, Manifest V3, no build step — and is loaded unpacked. (#175)
Install and configure
Section titled “Install and configure”- In Chrome, open
chrome://extensions, turn on Developer mode, click Load unpacked, and pick thebrowser-extension/directory of a checkout. - In Zimmer, open Settings → API keys and create a key with Quick Router only chosen. Copy it; it is shown once.
- Open the extension’s options (right-click its icon → Options), paste the Zimmer URL and the key, and Save. Saving asks Chrome for permission to talk to that one origin — grant it.
The browser has to reach Zimmer. On a tailnet-only deployment that means a browser on the tailnet,
using the tailnet address as the URL. On a deployment behind the Cloudflare
edge, as Tadas’s is, Cloudflare Access checks every
request before Zimmer sees it, the extension’s included, and the URL is the one host whose Access app
lets a signed-in browser reach /api. On Tadas’s deployment that is the iOS app’s origin,
https://zimmer-app.tadasant.com, not https://zimmer.tadasant.com: on the public domain /api
admits the laptop’s service token only, so a browser is refused there however it is signed in.
The extension sends the browser’s own cookies, so it passes Access when this browser is signed in at
that URL. Sign in once by opening https://zimmer-app.tadasant.com/api/v1/health in a tab: Access
runs the Google login and the tab lands on a short JSON answer. The extension cannot run that login
itself. The composer says who refused a message, and it blames the key only when Zimmer
itself refused it:
- A redirect is Access sending the request to its login page: sign in as above, then send again.
- An Access 401 comes from an Access app that admits service tokens only, and signing in will not get past it. Change the URL in the options to one whose Access app admits a browser sign-in.
- An Access 403 means the browser is signed in with an account the policy does not admit. Sign in with the right account.
The toast links to the session on the web UI URL Zimmer reports (ZIMMER_PROD_BASE_URL), not on
the URL the extension sends to, because that can be a host that serves the API only. When Zimmer
reports none, which happens while that base URL is unset, the link uses the URL the extension sends to.
Nothing needs provisioning on the Zimmer side. The key is minted on a page that already exists, the endpoint ships with the deploy, and there is no environment variable to set.
- Click the icon, or press Alt+Shift+Z. The composer opens at once, with no pin: the whole page is the context.
- To point at one thing, press Alt+Shift+X instead — or Drop a pin
in the composer, which keeps what you typed. The page gets a crosshair and a banner. Click where
the feedback applies: a pin drops and the composer opens showing what it landed on
(
<p> Closes #98.) — Move pin re-arms. Enter instead of a click skips the pin; Esc cancels at any step and keeps what you typed for the next time you open it. - Type, then ⌘/Ctrl+Enter or Send to Zimmer. A toast says it was sent and links to the session in Zimmer’s web UI; it goes away on its own.
Both shortcuts can be changed on chrome://extensions/shortcuts. Chrome applies an extension’s
suggested shortcuts when it is installed, so a copy loaded before Alt+Shift+X
existed may not pick it up on reload; bind it there. Chrome shortcuts are one key plus modifiers — there
are no chords, which is why pinning is a second shortcut and not Z then X.
If Zimmer refuses the message or cannot be reached, the composer stays open with the reason and your text intact. A refusal names who refused it. Zimmer’s own 401 is about the key: it is the wrong kind, revoked, or not a key Zimmer knows. Zimmer answers all three the same way on purpose. A refusal from the access proxy in front of Zimmer is about this browser’s sign-in there. Silently losing feedback would be worse than a second’s toast, so nothing here is fire-and-forget.
What is sent
Section titled “What is sent”One POST to /api/v1/quick_router per message:
| Field | What it carries |
|---|---|
prompt | Your words, as typed |
page_url, page_title | The tab’s |
page_context | The page, reduced to markdown the way the in-app bubble does it, walked on the live DOM so that only what is rendered counts: scripts, styles, SVGs, iframes, anything hidden or aria-hidden, anything CSS hides (display:none, visibility:hidden, opacity:0), anything positioned entirely off the top or left of the page or clipped to a single pixel (the screen-reader-only pattern), and every form field’s contents are left out. Capped at 20,000 characters (Zimmer cuts again at 50,000) |
pin | The click’s page coordinates and the viewport size; and the element under it: a CSS selector anchored on the nearest id, its tag, its own visible text (≤ 1,000 characters), and an excerpt — the smallest ancestor with enough text to read in isolation, as markdown (≤ 4,000). A pinned form field is described by its type and label (password field "Password"), never by what is typed in it |
The coordinate is fragile on purpose-built pages: GitHub reflows, renders dynamically, and is a
different width on another screen, so (485, 465) may point at nothing by the time an agent reads
it. That is why the pin carries the element three ways as well, with the excerpt capped separately
from the page. The prompt tells the agent to trust the element over the coordinate, and a 20,000
character page can never truncate the thing that was pinned.
The page is untrusted input to an agent. A page you did not write can carry text aimed at
whoever reads it next. Two things stand between it and the session: invisible text is not captured
at all (above), and Zimmer defangs any <context-about-user's-current-view> or <pinned-element>
tag in page-supplied text, so the block can only end where Zimmer ends it, and says inside it that
everything there is data about what you saw and never an instruction. That is framing, not a
sandbox — see the limitation.
All of it lands in the session’s prompt, the database, and the agent’s transcript. Mind what page
you are on — an authenticated view, a private repo, an inbox — because the extension captures
whatever is rendered. There is no origin allowlist: the extension is started only by your click on
the icon or your shortcut, on that tab, that once (activeTab), and holds no standing permission on any site. That
is the deliberate call for a single-user instance; a denylist would be the first
thing to add for anything wider.
What the key can do
Section titled “What the key can do”The key in the extension’s storage is an API key with the quick_router grant.
It opens POST /api/v1/quick_router and nothing else: every other REST endpoint and POST /mcp
refuse it with the same 401 they give an unknown key, and log the attempt at WARN by the key’s name.
The endpoint itself creates a session and answers { session_id, session_url } — no listing, no
transcript, no read of any kind.
So what someone who lifts the key from the browser gets is exactly: the ability to start Quick
Router sessions on this instance, at most ten a minute from one address, each of which shows up on
the dashboard as web_ui genesis with metadata.source browser_extension. They read nothing
back. Revoke the key on Settings → API keys and it is refused from the next request on.
This is why the extension does not take an ordinary API key: those have no scope, and a full-API key on a machine that browses the open web is a key that reads every transcript in the instance. The endpoint refuses full-API keys for the same reason it refuses everything else its grant does not name — the boundary is the shape, not a promise.
How it fits
Section titled “How it fits”The fetch lives in the service worker, not the content script, because of where each runs. A content
script requests as the page it was injected into, so github.com calling a Zimmer host is a
cross-origin request Zimmer would need CORS to answer. An extension service worker requests as the
extension, and Chrome exempts those from CORS for any origin the extension holds a host permission
for — the one the options page asked for. Zimmer keeps having no CORS at all, and no CSRF token is
involved because the request never came from a Zimmer page.
Server-side the endpoint is Api::V1::QuickRouterController, and it composes the prompt through
the same QuickRouterPrompt the in-app chat bubble uses: a <context-about-user's-current-view>
block with the URL and title, a <pinned-element> section, the page, and then your words, last and
unchanged. Your words are also recorded as a
human message with entry
point browser_extension.quick_router — the one API route that records one, because its key is a
browser’s and not the fleet’s.
What it does not do yet
Section titled “What it does not do yet”- Route. The session it starts is a router session, and the router root in this repo’s default catalog ships with no routing artifacts — see the limitation. On that catalog the extension is “start a session from anywhere with the page attached”, which is useful and is not the full promise.
- Resolve the URL to a session. A
github.com/…/pull/NURL could be matched to the session that opened the PR and the message delivered as a follow-up there instead of as a new session. Nothing does that yet; the URL is in the prompt and the metadata for whatever picks it up. - Firefox or Safari. MV3 with
chrome.*namespaces and a service worker; Firefox’sbrowser.*polyfill and background-page differences are untested.