browse is a stdio MCP server with a single tool. A session hands it a goal
in plain words. Jev chooses each click and keystroke, and the page comes back
as JSON. The session pays for one tool call, not for a navigate, snapshot and
click loop of its own.
Jev is here to make browsing faster and cheaper. It is not a security control, and nothing in this directory claims to be one.
It sits beside the Playwright MCP servers and removes none of them. Airlock's
R11-browse-via-jev rule denies a Playwright MCP browsing call and names this
tool. docs/rules.md
has that half.
The agent it drives is vendored at vendor/jev-ultrafast, so a checkout
already has it. What it still needs is that project's Python environment,
which browser/ syncs.
browser/install.sh # the agent's dependencies, once
browse/install.sh # checks both, runs a real handshake, prints the blockinstall/install.sh --browse-mcp runs the second one, and --all includes it.
It is not part of the default set.
Nothing here edits ~/.claude.json. The installer prints a block like this
one for you to add under mcpServers, with the absolute path on your machine:
{
"mcpServers": {
"browse": {
"type": "stdio",
"command": "/home/you/code/jev-kit/browse/server.py",
"args": [],
"env": {}
}
}
}install/doctor.sh runs the server through initialize and tools/list over
a real pipe and reports PASS. With no agent environment it reports a skip.
| Input | Type | |
|---|---|---|
goal |
string, required | What to do, in plain words. |
start_url |
string | Where to start. Optional when the goal contains an http(s) URL. |
extract |
string | A CSS selector. The text of every match comes back as extracted. |
screenshot |
boolean, default false | Write a PNG of the final page and return its path. |
links |
boolean, default false | Return links, the final page's element table: the same candidates Jev chose between. |
rank_goal |
string | Rank off-screen links against this text instead of goal. |
plan |
boolean, default false | Put a warm Claude planner in front of Jev. For open-ended tasks only; see Plan mode. |
plan_model |
sonnet or haiku |
The planner's model. Default sonnet, or JEV_PLANNER_MODEL. |
The result is JSON text:
| Field | |
|---|---|
final_url, title |
Where the agent ended up. |
status |
done when Jev judged the goal met, blocked when it could not go on. |
steps |
How many decisions Jev made. |
elapsed_ms |
Wall time for the whole call, Chromium start included. |
text |
Visible text of the final page, trimmed to 8 KB. text_truncated is set when it was cut. |
extracted |
Text at extract, only when you gave one. |
screenshot_path |
Only when asked for. A PNG under $XDG_STATE_HOME/jev-kit/browse/, else ~/.local/state/jev-kit/browse/. |
timing |
Where the time went: decisions, ops, jev_ms, confidence, rechecks, text_ms, agent_ms, total_ms, jev_transport. |
plan |
Only with plan: true: the planner's model, turns, finds, transcript, planner_ms, browse_ms, tokens and cost_usd. |
timing.ops is one entry per decision Jev made, not per action executed, so a
decision the executor threw away because the page moved under it shows up there
and nowhere else. That is the first thing to look at when a call takes longer
than its step count explains.
Only http:// and https:// start pages are accepted.
A real run on the development box, 2026-09-23, driven over stdio:
goal: "open https://example.com and report the main heading"
extract: "h1"
{
"final_url": "https://example.com/",
"title": "Example Domain",
"status": "done",
"steps": 1,
"elapsed_ms": 1392,
"text": "Example Domain\n\nThis domain is for use in documentation examples without needing permission. Avoid use in operations.\n\nLearn more",
"extracted": "Example Domain",
"timing": {"decisions": 1, "ops": ["DONE"], "jev_ms": [431], "text_ms": [],
"agent_ms": 435, "total_ms": 977, "jev_transport": ["daemon"]}
}That is one run, so treat the 1.4 seconds as an example and not a benchmark. It is the first call of a server, so it includes starting Chromium and the worker.
server.py is standard library only. It speaks JSON-RPC 2.0 over stdio:
initialize, notifications/initialized, ping, tools/list, tools/call.
The agent runs in one long-lived worker process, runner.py, on the vendored
project's own environment. That environment sits at
$AIRLOCK_HOME/jev-ultrafast-venv, outside any release, and holds the
dependencies only; jev_ultrafast is imported from whichever source tree the
server resolved, which the worker is told through PYTHONPATH. A .venv
inside the source tree is used if one is there, and uv run is the last
resort.
One worker for the life of the server, not one per call. A fresh process per
call paid for the agent's imports, a cold browser_harness daemon and a cold
text model every single time, and that was most of the fourteen seconds the
README's own Wikipedia example used to take. The timeout is still a hard one:
the worker's whole process group is killed when the time is up, and the next
call gets a new worker. So does a worker that died on its own, or one that was
started against a Chromium which has since been replaced.
JEV_BROWSE_PREWARM=1 starts Chromium, the worker and the text model when the
server starts rather than when the first call arrives. It is worth about a
second on that first call and nothing after it, so it is off by default: a
session that merely has this server configured should not be paying for a
headless Chromium it never uses. Turn it on for a session you know will browse.
TYPE_TEXT needs a model to write the value, and this server picks one rather
than leaving it to chance.
| when | what runs | |
|---|---|---|
| Warm Haiku | the default, no key anywhere | One standing claude child on your ordinary login, thinking off, trimmed field context. It lives as long as the server does. |
| OpenAI-compatible | TEXT_MODEL_API_KEY is set |
Upstream's helper, defaulting to OpenRouter and inception/mercury-2.5 with reasoning off. |
The key can be an ordinary environment variable, or a TEXT_MODEL_API_KEY=...
line in the same key file the TypeSafe key lives in (~/.config/jev-kit/env,
and airlock/keyfile.py is where the resolution order is written down). It is
optional: nothing here requires one, and the kit does not add one.
Measured on this box, same goal, five calls in one session: warm Haiku writes a
field value in 0.70 to 0.86 s. Upstream reports Mercury doing the same job in
roughly 0.35 s, so a key is worth having if you already have one and is not
worth getting if you do not. TEXT_MODEL_PROVIDER set by hand overrides both,
and vendor/jev-ultrafast/SPIKE-NOTES.md has the full measurements.
Jev's own decisions go through airlock's warm daemon when it is running, which saves about 70 ms on the first decision of a worker and about 25 ms after it, and fall back to a direct HTTPS call when it is not.
The server owns the Chromium lifecycle, and only its own.
- If you set
BU_CDP_URLand something answers there, it attaches to that and never closes it. This is how you hand it a browser that is already logged in. Unset means unset: the oldhttp://127.0.0.1:9333default is not probed any more, because whatever answers there is somebody else's browser unless you said otherwise. - Otherwise it starts a headless Chromium of its own on a free port, with its own temporary profile. Every call this server process serves reuses it, and it closes when the server exits. If it has died since, the next call starts a fresh one and removes the dead one's profile directory.
- Other Chromiums on the box are ignored. A Playwright MCP browser, a second Claude session running its own copy of this server, an ordinary desktop Chrome: none of them is attached to, none is killed, and none stops a call. Until 2026-09-23 any one of them made every call fail, which taught agents to go back to Playwright.
It fails closed with a message and it never hangs. Every failure below comes
back as an isError result, and the read loop keeps going.
| What went wrong | What the message names |
|---|---|
| No agent source | vendor/jev-ultrafast and JEV_ULTRAFAST_DIR |
| No TypeSafe key | ~/.config/jev-kit/env |
The call ran past JEV_BROWSE_TIMEOUT (default 90 s) |
the timeout. The agent is killed, and a Chromium this server owns is restarted. |
| No Chromium binary anywhere | npx playwright install chromium and JEV_BROWSE_CHROMIUM |
| Chromium has no usable sandbox | JEV_BROWSE_NO_SANDBOX, see below |
| Our Chromium exited during the call | the binary and its own last FATAL line. The call is not replayed, because its actions may already have taken effect. The next call starts a fresh browser, from another installed build when there is one. |
| A bad argument | the argument |
status: blocked means Jev gave up on the goal. It chooses one action at a
time, out of what it can see in the viewport, so a task that needs several
hops is beyond it. A goal written as explicit steps gets further than a goal
written as an outcome. "Open the article, click the link to X, then click the
link to Y, scroll if the link is not in view" is the shape that works.
When it is beyond browse anyway, airlock notices. A PostToolUse hook,
hooks/airlock_browse_unlock.py, records a blocked result and an errored
call alike, and R11-browse-via-jev then warns instead of denying Playwright
MCP for the next thirty minutes of that session. There is nothing to do by
hand: try browse first, and a failure hands the browser back.
docs/rules.md has
the detail, including what a subagent shares with its parent.
Key resolution is the same as everywhere else in the kit, and the order is
written down once, in the module docstring of airlock/keyfile.py. The child
gets the key in its environment and never on a command line. It is scrubbed
from anything the tool returns.
Chromium is started with its sandbox on. On Ubuntu 23.10 and later, AppArmor denies user namespaces to a binary that has no profile. A Chromium from the Playwright cache is such a binary, so there it refuses to start.
The server does not work round that for you. The error says what happened, and
JEV_BROWSE_NO_SANDBOX=1 in the server's env block starts Chromium with
--no-sandbox. That means a page which exploits the renderer is no longer
contained. Playwright makes the same trade by default when it launches
Chromium itself, so this is no worse than the Playwright MCP server beside it.
It is still your decision and it is never the default. The better fix is an
AppArmor profile for the binary.
Plain browse is the default, and it is the right call whenever the goal can
spell out the steps: "open X, click Y, then click Z". Jev picks one action at a
time out of what it can see, and it is fast at that.
It is not built to work out a route. For an open-ended task, "find the year the
author of X was born", set plan: true. A planner, one warm claude -p child
with thinking off and --effort low, reads the page's element labels and names
one step per turn:
CLICK <label> the Jev agent executes it on the current page
TYPE <field> = <text>
FIND <words> every element on the page matching the words, however far down
DONE <answer or ok>
The planner is Sonnet by default. plan_model: "haiku" (or
JEV_PLANNER_MODEL=haiku) is cheaper and does as well on spelled-out hops, and
much worse on open-ended ones; the numbers are in the main
README.
What it costs:
- Money. The planner runs on the user's own
claudelogin and is billed there, a few cents a task. Plainbrowsespends nothing on Claude. - Time. Every turn is a planner answer plus an agent run. Expect tens of seconds a task, not the few seconds a spelled-out goal takes.
- A process. The child starts on the first planned call, never before, and stays warm for the life of the worker. A session that never plans never starts one.
JEV_BROWSE_TIMEOUT defaults to 180 seconds for a planned call instead of 90,
and the loop stops itself a few seconds early so the page it reached still
comes back.
| Variable | Default | |
|---|---|---|
JEV_ULTRAFAST_DIR |
vendor/jev-ultrafast beside the server |
The agent's source. Resolved from server.py, so a deployed release finds its own copy. AIRLOCK_BROWSER_DIR is honoured after it. |
JEV_ULTRAFAST_VENV |
$AIRLOCK_HOME/jev-ultrafast-venv |
The agent's Python environment. Outside the release on purpose, so a deploy does not rebuild it. |
BU_CDP_URL |
unset | A Chromium to attach to instead of starting one. Only an explicit value counts. |
JEV_BROWSE_TIMEOUT |
90 |
Seconds allowed for a call. |
JEV_BROWSE_CHROMIUM |
newest in the Playwright cache, then PATH |
The binary to start. A pre-2021 chromium-8xxxxx directory ranks below any current one. |
JEV_BROWSE_NO_SANDBOX |
unset | 1 adds --no-sandbox. Read the section above first. |
JEV_BROWSE_PREWARM |
unset | 1 warms Chromium, the worker and the text model at start-up. |
TEXT_MODEL_API_KEY |
unset | Selects the OpenAI-compatible helper. Also read from the key file. |
TEXT_MODEL_PROVIDER |
chosen for you | Set by hand to override the choice above. |
JEV_OFFSCREEN_MAX |
100 |
How many off-viewport links a snapshot may offer. 0 is upstream's viewport-only behaviour, -1 fills the budget. |
JEV_SYSTEMONE_SOCKET |
the airlock daemon's socket | An empty value sends every decision straight over HTTPS. |
JEV_PLANNER_MODEL |
sonnet |
The planner for plan: true when the call names none. |
JEV_PAGE_TEXT_CHARS |
see model.py | Characters of page text in Jev's state per decision. |
JEV_DONE_CONFIDENCE, JEV_BLOCKED_CONFIDENCE |
see agent.py | Below this, a DONE or BLOCKED is looked at again once before it ends the run. |
A long article keeps almost every link out of the viewport, and upstream sends
only what is in it. The snapshot also offers the nearest off-viewport links,
labelled (below) or (above), and the executor scrolls one into view before
it resolves geometry and hit-tests as usual. Fields are never offered that way:
typing into something nobody has seen is a different kind of risk.
JEV_OFFSCREEN_MAX bounds how many, and 100 is a measured default rather than
a round number. The curve is not monotonic. A cap too small to reach the link a
goal actually wants is worse than offering none at all, because it fills the
table with that link's neighbours and the agent clicks one of them instead of
scrolling. The sweep is in
SPIKE-NOTES.md.
The same as the browser agent. The goal and the observed page state go to TypeSafe for each decision. Field text goes to whichever text model the agent is configured with. The page text and the screenshot stay on the machine.
Native Windows is not supported, because the browser agent is not ported there.
tests/test_browse.py covers the framing, the handshake, the schema and every
failure in the table above. The call into the vendored agent is mocked. No test touches
the network or starts a browser.