BOOK A CALL

All projects

Case study

Claude for Safari

A browser agent on a platform that is actively hostile to one — and the invisible crash that turned every constraint into the architecture.

Platform
Safari · macOS
Stack
Plain JavaScript. No bundler, no dependencies.
Status
Working end to end
Scale
5,600 lines across 15 files · 17 tools

The problem

Browser agents exist for Chrome. Safari has no side-panel API, no debugger protocol, inconsistent module support in service workers, a content policy that blocks remote scripts, and it will not load an extension as a folder — it has to ship inside a signed, sandboxed macOS app. Every affordance the Chrome version leans on has to be rebuilt out of something else.

The crash that defined the design

A hard kill with no evidence

Safari’s UI process terminates the host page’s web content process when an extension page framed inside that page queries the tabs API. No exception, no console output, no crash report. The panel, the content scripts and the page all disappear at once — and because the panel’s console dies with it, there is nothing left behind. It was invisible for a long time for exactly that reason.

Three things came out of it, and together they are the architecture.

  1. The panel never resolves its own tab

    The background hands it down at mount as a parameter on the frame URL. No file running in the page’s process may touch a tabs or windows API at all.

  2. The agent loop lives in the background worker

    Not in the panel. A task survives the user switching tabs, and sidebars became views onto one conversation rather than owners of separate ones. The constraint turned into the better design.

  3. Diagnostics are append-only, in the surviving process

    The background is the only thing that outlives the crash, so it keeps the breadcrumb trail. The first version kept one trail and rewrote it wholesale — which destroyed the evidence, because the extension restarted after the crash and the single crumb it wrote replaced everything from the run that actually died.

The rule is now enforced mechanically: the test suite fails if any file in the page’s process references the tabs or windows APIs, and separately if the property the fatal query carried appears anywhere at all.

The test suite is a list of scars

Every regression guard encodes a bug that actually shipped and cost real debugging time. It runs with no browser at all. A sample:

  1. The streaming path may perform no layout reads

    The panel is a frame inside the host page, so any layout query on that path lays out the host document too — about 13ms on a heavy page. Hundreds of deltas per turn made Safari kill the page.

  2. The fatal-error handler must catch its own rejection

    It is the unhandled-rejection handler, so a rejection of its own feeds it forever.

  3. The console capture channel must not be postMessage

    postMessage is a public bus that page code listens on, and the capture script patches the console — so a page listener that logs anything closes a loop. Measured on a real article, one log line produced 18,000 round trips in 600 milliseconds. Analytics, embeds and devtools bridges all listen for messages, so this is the common case, not an exotic one.

  4. No deep cloning in the page agent

    Computing an accessible name once cloned every element’s subtree: 680,000 DOM nodes per read.

  5. The sidebar must not accumulate streamed text in an attribute

    Re-rendering the whole response per repaint is quadratic in its length.

The decision worth defending

DOM-first, not screenshot-first

Perception is an accessibility-style tree where every interactive node carries a handle, and actions address those handles. Screenshots are supplementary, used when layout or styling actually matters.

Three reasons, and the third is the one people miss. Handles survive reflow; coordinates do not. A tree costs far fewer tokens than an image. And it gives the permission engine a real label to reason about — the user is asked “Click Delete account”, not “Click 400,320”. A coordinate-based agent cannot build a meaningful consent prompt, because it does not know what it is about to click.

Safety, enforced twice

The permission engine is independent of the model. Everything funnels through one evaluation, and no tool executes without a verdict. Three modes, plus per-site rules that persist.

A prohibited class is refused outright and is not offered anywhere in the interface: payment credentials, passwords, personal data entry, account creation, captchas, purchases, destructive actions, sending messages, form submission, consent banners, authorisation grants. No mode, no site rule, and no explicitly worded user request turns any of them on.

The system prompt tells the model the same rules. The belt-and-braces structure is the point: the prompt is guidance and the engine is enforcement, and neither is trusted to be the only one.

Prompt injection has a specific defence

The Markdown renderer is hand-rolled, and the order is load-bearing. Input is escaped first, and every transform afterwards only emits tags the file wrote itself. Model output and page-derived text both flow through it, so escaping last — or not at all — would turn an injection on a web page into script execution inside the extension’s own origin. Only http and mailto survive as link targets.

Where it stands

Working end to end: sidebar, agent loop, seventeen tools, permission engine, site rules, attachments, history, diagnostics, and a build that installs and registers with Safari.