jev-browser-pilot

September 18, 2026 ยท View on GitHub

CI License: MIT Python 3.9 to 3.13

A bounded decision layer for browser and desktop automation. Your code observes the surface, builds the list of things it could do, executes one of them, and checks the result. A decision-only model makes exactly one choice per step: which candidate is next.

observe (your code)  ->  decide (the model, one typed choice)  ->  act (your code)  ->  verify (your code)

The loop is not new. What is different is the split. The model never sees a screenshot, never writes a selector, never produces text to type, and is never asked whether it succeeded. It answers one bounded question over a list of ids your code built, and returns a confidence your code routes on. Everything that can fail silently stays in code.

That buys two things: a step that costs a fraction of a cent and answers in a few hundred milliseconds, and an audit trail that shows exactly what the model was shown at every step.

Install

pip install jev-browser-pilot              # core: browser surface over CDP
pip install "jev-browser-pilot[jev]"       # + the TypeSafe Jev provider (Python 3.10+)

Or from a clone, which is what the commands below assume:

git clone https://github.com/aidil2105/jev-browser-pilot
cd jev-browser-pilot
python -m venv .venv
.venv/Scripts/python -m pip install -e ".[dev,jev]"    # Windows
# .venv/bin/python -m pip install -e ".[dev,jev]"      # macOS and Linux

The only runtime dependency is websocket-client. The browser surface uses the Chrome, Chromium or Edge you already have. The Jev provider needs Python 3.10 or newer because the vendor SDK does; on 3.9 the core, the OpenAI-compatible provider and the mock chooser all work and the Jev provider says so plainly instead of failing to install.

Releases are built and uploaded by .github/workflows/publish.yml when a GitHub release is published, using PyPI's trusted publishing rather than a stored token; docs/publishing.md has the one-time PyPI setup and the local fallback.

Quickstart

Three commands, in this order. The first one needs nothing at all.

1. Prove the loop works, offline. No browser, no network, no credentials:

$ jev-pilot selftest
jev-browser-pilot 0.1.0 selftest
  step 1: act pick=2 link 'Omega appendix' 0ms
  pass: url-contains:omega.html
  pass: text-contains:goal reached
  reached=True stopped=reached steps=1 actions=['2']
  pass: unknown id is an error
  pass: low confidence escalates with the pick
  pass: transport failure is an error
selftest: PASS

2. Borrow just the decision step. JSON in, JSON out, so any harness can use it without adopting the loop:

$ echo '{"goal":"open the Omega appendix",
         "state":"CANDIDATE ACTIONS:\n[1] link (Alpha report)\n[2] link (Omega appendix)",
         "options":[{"id":"1","label":"link (Alpha report)"},
                    {"id":"2","label":"link (Omega appendix)"}]}' \
  | jev-pilot decide --provider mock
{
  "outcome": "act",
  "pick": "2",
  "label": "link (Omega appendix)",
  "confidence": 0.8,
  "provider": "keyword",
  "reason": "picked"
}

Output trimmed to the fields that matter; the real object also carries the full probability map, tokens, cost and latency. The mock provider reports itself as keyword, which is what it is: a deterministic keyword overlap, so the quickstart needs no key and no network.

3. Drive a real site. The browser gets its own profile and its own debugging port, and the hosts it may touch are declared up front:

$ jev-pilot run --start "https://en.wikipedia.org/wiki/Calculator" \
      --task-file examples/wikipedia-chain.json \
      --provider jev --headless --trace run.jsonl --report run.html

Exit codes are part of the contract, because this is meant to run in a pipeline: 0 every goal reached its postcondition, 1 a goal did not reach it, 2 a hard error (no browser, no credentials, a safety refusal, bad input).

What a run looks like

Real output, three chained goals in one browser session, one decision per hop:

jev-browser-pilot 0.1.0
surface: browser | chooser: jev model=jev-latest
safety: hosts=[en.wikipedia.org] actions=['click'] typing=off dry_run=False max_steps=12
session: chrome pid=9804 port=33154 profile=33154

=== goal: open the article about the slide rule
  step 1: act conf=0.95 pick=100 a 'slide rule' 1454ms
  pass: url-contains:Slide_rule ('Slide_rule' in 'https://en.wikipedia.org/wiki/Slide_rule')
  result: reached=True stopped=reached steps=1 median=1454ms cost=\$0.000270

=== goal: open the article about William Oughtred, the inventor of the slide rule
  step 1: act conf=0.99 pick=14 a 'William Oughtred' 297ms
  pass: url-contains:William_Oughtred ('William_Oughtred' in 'https://en.wikipedia.org/wiki/William_Oughtred')
  result: reached=True stopped=reached steps=1 median=297ms cost=\$0.000286

=== goal: open the article about Richard Delamain, the rival who claimed priority for the slide rule
  step 1: act conf=0.47 pick=88 a 'Richard Delamain' 295ms
  pass: url-contains:Delamain ('Delamain' in 'https://en.wikipedia.org/wiki/Richard_Delamaine')
  result: reached=True stopped=reached steps=1 median=295ms cost=\$0.000301
report: run.html

Three goals, three decisions, $0.00086 for the session. Note the third hop: it is the only one whose goal describes a person rather than nearly quoting the link text, and it is the only one answered at 0.47 confidence. Right answer, visibly less sure. That confidence is the signal you route on.

Why this shape

An agent that reads a page with a full language model pays seconds and cents per step for a judgment that is often a single pick among a handful of visible options. Measured on five frozen observations of a real window, three repeats each, no capture and no actuation involved, so this is the decision alone (examples/bench-calculator.json):

choosercorrectaccuracyansweredmedianrangecost
jev15/15100%15/15349 ms277 to 870 ms$0.000393
openai, a mid-tier chat model12/1580%12/152,858 ms4 to 15,328 msnot metered here
mock, keyword overlap3/1520%15/150 ms0 msfree

Read that honestly: the chat model's three misses are all the same transport failure on the hardest state (a relay 503, not a wrong answer), so the column that matters most between it and jev here is latency. jev answered every call, never wrong, at 0.93 to 1.00 confidence on the four deterministic states and 0.78 on the one that relies on reading a display. The mock chooser fails every state whose answer is not in the goal's own words, which is exactly what a naive keyword overlap should do.

Reproduce it, or run it against your own states:

jev-pilot bench --fixtures examples/bench-calculator.json \
                --chooser jev --chooser mock --chooser openai:llama3.1 \
                --base-url http://localhost:11434/v1 --repeats 3

The mock chooser is a deterministic keyword overlap stand-in, deliberately naive: it exists so the library, its tests and its docs work with no key, no network and no spend. It is a fixture, not a baseline to beat.

What this is not

  • Not an autonomous agent. There is no lookahead. If the next step is not derivable from the current observation, the model answers __stuck__ instead of guessing. Put a planner above it.
  • Not a text generator. It cannot produce the string to type, the URL to open or the value to set. Your code owns content. It also means no prompt injection can make it invent one.
  • Not a success oracle. Postconditions are evaluated by code, before and after every action. The model's done is a report, not a proof.

How it fits together

piecewhat it does
Surfacewhatever you are driving: BrowserPilot, DesktopPilot, ScriptedSurface, or your own object with observe/act/url/probe
perceptionturns a page or a window into Elements and mints the candidate Option ids
serializerenders the exact state string the model sees
Chooserthe decision backend: jev, openai (any compatible endpoint), mock, scripted, or yours
policywhat an answer is allowed to mean, in code rather than in a prompt
verifydeterministic postconditions: URL, text, CSS selector, file contents
safetydeclared hosts, allowed actions, typing off by default, dry run
traceJSONL per step plus a self-contained HTML report
benchthe same frozen states replayed against several choosers

The library API is small enough to quote:

from jev_pilot import SafetyPolicy, run_episode
from jev_pilot.browser import BrowserPilot
from jev_pilot.providers import build_chooser

safety = SafetyPolicy.for_url("https://en.wikipedia.org/wiki/Calculator")
chooser = build_chooser("jev")          # or "openai" with model=..., or "mock"

with BrowserPilot("https://en.wikipedia.org/wiki/Calculator", headless=True,
                  safety=safety) as pilot:
    episode = run_episode(
        pilot, chooser,
        goal="open the article about the slide rule",
        verifiers=["url-contains:Slide_rule"],
        steps=3, floor=0.6, safety=safety,
    )

print(episode.reached, episode.stopped, episode.summary())

The decision policy

One question per step: a single choice over the candidate ids, plus two sentinels. The rules that turn an answer into an outcome live in policy.py:

outcomemeaningwho handles it
acta candidate was chosen and is safe to executethe loop clicks it
donethe model reports the goal is achievedthe loop stops; the postcondition still decides reached
stucknothing in the list can make progressthe loop stops; a planner above decides what next
escalatethe pick is under the confidence floor, with the pick attachedyour planner, or a human
errorunknown id, empty answer, transport failureyour caller; never treated as a pick

Where an episode can stop is equally explicit: reached, done, stuck, escalate, error, blocked (the surface left the declared hosts), no_progress (the same state twice in a row), budget, dry_run.

Asking one question instead of two is deliberate: an earlier version asked for the action and the target separately and produced contradictory answers, picking an element while also declaring the task done. One question, one answer, one decision.

Providers, including yours

namewhat it isnotes
jevTypeSafe Jev, a decision-only modelneeds TYPESAFE_API_KEY; returns the full probability map
openaiany OpenAI-compatible endpointOllama, vLLM, LM Studio, a gateway; needs --model, no confidence returned
mockdeterministic keyword overlapcredential-free, for tests, docs and offline development
scripteda fixed sequence of answersassertions in tests

Where a TypeSafe key comes from. jev is the default chooser and it is early access: at launch (September 2026) TypeSafe opened early access and is bringing developers in from a waitlist, so typesafe.ai is the way to ask for access. Once you are in, the key is in the dashboard (docs.typesafe.ai/introduction/quickstart), and the client reads it from TYPESAFE_API_KEY. Until then mock and openai need no TypeSafe account at all, which is enough for the quickstart, the tests and the bench: the CLI exits 2 with a message naming the variable and the alternatives if you ask for jev without a key.

A chooser is one method, so adding yours does not touch the loop, the safety rails or the verification:

from jev_pilot.providers.base import Chooser, RawAnswer

class MyChooser(Chooser):
    name = "mine"

    def choose(self, *, goal, state, options, timeout=None):
        answer = my_service(goal, state, [o.as_dict() for o in options])
        return RawAnswer(pick=answer["id"], confidence=answer.get("confidence"))

RawAnswer is not permission to act. It goes through apply_policy, which is where the floor, the sentinels and the fail-closed rules apply to every provider equally.

Postconditions

Verification is a list of specs, checked before the first decision and after every action. The success decision never involves the model.

specpasses when
url-contains:TEXTthe current URL contains TEXT (case sensitive)
url-regex:PATTERNthe URL matches the pattern
text-contains:TEXTthe visible text contains TEXT (case and whitespace insensitive)
selector:CSSthe page has at least one matching element
file-contains:PATH::TEXTthe file exists and contains TEXT

Prefer a postcondition the page cannot fake. text-contains: is the weakest of them, because marketing copy that merely mentions the word passes it. A minted order id, a confirmation element or a file that a download had to produce are all stronger.

Safety rails

Opt-in by construction, because this drives a real browser on a real machine.

  • Hosts are declared, not inferred. Navigation is confined to the hosts you name (a file:// page reports the pseudo-host file). The moment an observation lands somewhere else, the episode stops with blocked, that outranks a postcondition which would have passed there, and a chain abandons its remaining tasks.
  • Only click is allowed by default. Typing is off until you enable it, and there is no API in this library for passwords, tokens or card numbers at all.
  • The browser is isolated. Its own --user-data-dir and its own debugging port, so it cannot attach to a logged-in profile.
  • --dry-run decides and records, and stops before anything is actuated. Useful when wiring a new surface, and useful for showing what the model would have done.

The rails are checked by the loop, not requested of the model, so a provider that hallucinates a better plan cannot walk around them.

Using it from an agent

Your agent already observes and acts. This library is the step in between, and there is a Hermes plugin for it at hermes-plugin/jev-decide/: one tool, jev_decide, that takes a candidate list you built and returns one typed answer, for about $0.0003 and 300 ms instead of a full agent turn. escalate comes back with the pick attached, error comes back with a null pick and a reason, and nothing is ever executed by the tool. See docs/hermes-tool.md for the candidate-enumeration snippet and the outcome table.

Benchmarks over frozen states

A live comparison mostly measures who got luckier with perception. bench removes that variable: one observation, frozen to a file, replayed against several choosers, scored against a known answer.

{"cases": [
  {"name": "after-plus",
   "category": "calculator",
   "goal": "compute 5 + 3, stopping when the display shows the answer",
   "state": "DISPLAY: 'Display is 5'\nACTIONS ALREADY TAKEN THIS EPISODE: Five, Plus\n[23] Button 'Plus'\n[24] Button 'Equals'\n[28] Button 'Three'",
   "options": [{"id": "23", "label": "Button 'Plus'"},
               {"id": "24", "label": "Button 'Equals'"},
               {"id": "28", "label": "Button 'Three'"}],
   "expect": "Button 'Three'"}
]}

expect is a label, an id: value, or a sentinel (__done__, __stuck__). A bare value that happens to match one of the fixture's own option ids is read as an id as well, because a fixture written with ids and scored against labels reports a perfect run as a total miss. The report gives you accuracy next to coverage, because a chooser that answers 6 of 10 calls and gets them right is not the same as one that answers all 10.

jev-pilot bench --fixtures examples/bench-calculator.json --chooser jev --repeats 3 \
                --markdown bench.md --out bench.json

A larger set: 27 frozen states from real pages

The five states above come from one window, and a set built to one shape flatters whatever fits it. This one is built to the pages: 27 frozen states from eight live Wikipedia articles, each paired with a goal, plus two abstention shapes on every page (the goal is already satisfied, and the goal cannot be reached from here).

Nothing in it comes from a model's answer. scripts/capture-bench.py reads each candidate's link target out of the DOM, which the harness knows and the chooser never sees, and sets the case's answer to the element whose link leads to the article the goal names. Where a page links the same article twice, the extra links are dropped so there is exactly one right answer.

python scripts/capture-bench.py --out examples/bench-decisions.json
python scripts/audit-bench.py            # every expect is a sentinel, an id:, or a label
python scripts/bench-breakdown.py report.json   # picks against abstentions
choosercorrectpicksabstentionsansweredmediancost
jev25/27 (93%)9/1116/1627/27316 ms$0.0070
qwen3-30b-a3b-instruct (open weights)10/27 (37%)10/110/1627/271,339 msabout $0.01
llama-3.1-8b-instruct (open weights)9/27 (33%)9/110/1627/271,146 msabout $0.01

Read the columns, not the totals. On picking, the three are indistinguishable: the open models found the named article as often as Jev did, one of them marginally more often. The whole difference is abstention. Given a state where the goal is already satisfied, or one where no candidate can reach it, both open models chose something to click every single time, 32 out of 32, where Jev answered done or stuck every single time. For a layer whose job is to keep a wrong answer from being executed, that is the column that decides whether it is worth having.

Jev's two misses are name matching rather than navigation: an article titled Richard Delamaine linked as Richard Delamain, and the article Logarithm linked as "logarithmic". When the goal's words are absent from the label it can pick the wrong link, or judge the page stuck.

Scope: eight pages from one site family, one repeat, three choosers. The two open arms are paid at their cheap rates, about a cent an arm, because OpenRouter's free tier rate-limited the same models (429 free-models-per-day-high-balance: a 1000-request daily cap, exhausted) and the local relay answered 26 of 27 calls with 503 empty response content. Those coverage failures are recorded with their verbatim messages in docs/evidence/bench-decisions/endpoint-failures.txt rather than quietly dropped.

The same tasks, two choosers, twice each

The frozen states above isolate a single decision. This runs the whole loop instead: one task file, one start page, the same postconditions, one arm per chooser, two passes, headless Chrome.

jev-pilot run --start https://en.wikipedia.org/wiki/Calculator \
  --task-file examples/wikipedia-chain.json --provider jev --headless

jev-pilot run --start https://en.wikipedia.org/wiki/Calculator \
  --task-file examples/wikipedia-chain.json --provider openai \
  --model <a free open-weights chat model> --base-url <an OpenAI-compatible relay> --headless
choosertasks reachedper-decision latencyper-decision cost
jev6 of 6302 to 982 ms$0.00027 to $0.00030
a free open-weights chat model4 of 62,733 to 51,665 msunmetered, free route

The four run summaries behind this table are committed under docs/evidence/live-comparison/, so the numbers can be read back instead of taken on trust.

What this does and does not say. Whenever the chat model answered it picked the same element Jev picked, every time, so the two agreed on all four decisions it completed. Its two failures were not wrong picks: they were calls that never returned, both on the same task, at 30 s and 52 s, from a relay that answered with an empty response where Jev answered in 302 ms at 0.49 confidence. Read it as a reliability and latency result under this harness, not as a ranking of two models' judgment. Three tasks, one site, two passes: enough to show the shape of the difference, not enough to generalise past it.

It also shows the exit codes doing their job: the arm that missed a task exited 1, the arm that reached all three exited 0.

Exit codes

Running run from a script is the point, so the codes are part of the interface.

codemeaning
0every task reached its postcondition
1at least one task was not reached: the postcondition failed, the chooser never answered, or the episode stopped blocked because the surface left the declared hosts
2a usage or configuration problem: an unknown chooser, an unreadable fixture, a missing credential

Confidence never turns a miss into a success. An episode where every decision was confident and the postcondition still failed exits 1, and the run prints which task it was.

Traces and reports

Every step is recorded: the exact state sent, the ids offered, the answer, the confidence, the latency, the tokens and the cost. jev-pilot report renders it into a single self-contained HTML file with no external assets, so it can be attached to a bug report or a pull request.

jev-pilot run ... --trace run.jsonl --report run.html
jev-pilot report --trace run.jsonl --out run.html      # render later

Use --no-verbatim-state when the page content must not be persisted. The decision is still recorded; only the state text is left out.

Known limits

  • Perception is the ceiling. On a Wikipedia article, 200 of 447 candidate links fit the cap. What falls past it is invisible, so a correct __stuck__ can mean "I cannot see it" rather than "it is not there". Check matched_total in the trace before blaming the model.
  • Confidence is a router, not a gate you set once. The same ambiguous hop scored 0.47 in one run, 0.49 in another and 0.61 in two more. Set the floor from the distribution you observe on your own task.
  • No lookahead, no typing, no re-planning. Those belong to the layer above.
  • The desktop surface is experimental. It drives a native window through a UI Automation tree via cua-driver. It has completed a real four-click episode (see below), but UIA quality varies by application and it has had far less use than the browser surface.
  • No screenshot perception. Text states only, by design: cheaper, auditable and diffable.

What has been verified

Every claim here was produced by a run, and the command is quoted with it so you can check it yourself.

claimevidence
the loop, policy and surfaces workpytest: over 170 credential-free tests, no credentials, no network
the browser surface drives a real browserpytest -m live: launches headless Chrome against a local fixture page and reaches its postcondition
the package installs and runs as a packageuv build, then install the wheel into a fresh venv with no extras: jev-pilot selftest prints PASS
the release lane publishes0.1.1 was uploaded by .github/workflows/publish.yml through PyPI trusted publishing, and pip install jev-browser-pilot in a clean venv pulls it: jev-pilot version reports 0.1.1 and selftest prints PASS
CI on three platformsGitHub Actions on main: 3.9, 3.11 on Ubuntu, Windows and macOS, 3.13, and the live browser job, all green
a live site, real model, through the CLIthe transcript above: three hops, 1454 / 297 / 295 ms, $0.00086
the same chain through the library APIpython examples/browser_chain.py --provider jev --headless: 3 of 3 hops reached
a real OpenAI-compatible endpointjev-pilot decide --provider openai --model <a chat model> --file examples/decide-request.json against a local OpenAI-compatible relay: correct pick in 4357 ms
the desktop surface, clickingjev-pilot run --surface desktop --aumid Microsoft.WindowsCalculator_8wekyb3d8bbwe!App --provider jev --steps 6 --verify text-contains:8: four real clicks, display verified from the window's own text, exit code 0, $0.000247
a second real desktop applicationjev-pilot run --surface desktop --attach-pid <explorer pid> --task-file examples/desktop-explorer.json: two navigations in a File Explorer window (163 elements, list and tree items), both verified from the file names in the window, $0.000525
the desktop path is covered without a windowtests/test_desktop_loop.py drives a scripted driver through a full episode: observe, pick, click by element token, verify from the window's own text
the frozen-state benchthe table above, examples/bench-calculator.json, three repeats per chooser
a 27-state bench from real pages, two open-weights armsexamples/bench-decisions.json: Jev 25/27 (16/16 abstentions), the open arms 10/27 and 9/27 (0/16 abstentions), all three with full coverage. docs/evidence/bench-decisions/
the same tasks against a second chooserthe table above: 6 of 6 tasks reached against 4 of 6, two passes each
the manual live lane runs on a GitHub runnerlive-jev.yml, dispatched by hand with the API key as a repository secret: three hops, 277 / 124 / 127 ms, $0.00086
an agent can borrow the decision stephermes plugins doctor jev-decide passes, and a live agent run called jev_decide through ordinary tool dispatch, receiving act with a pick. docs/hermes-tool.md

Still not verified, and said plainly rather than buried: any comparison that would support a claim about judgment rather than abstention and reliability, a bench over more than one site family and one repeat, and any platform other than Windows for the desktop surface. That surface has now driven two real applications, one episode each.

Requirements

  • Python 3.9 to 3.13 ([jev] needs 3.10+).
  • Chrome, Chromium or Edge for the browser surface. Headless is supported; set JEV_PILOT_CHROME to point at a specific binary and JEV_PILOT_CHROME_ARGS for extra flags (containers and CI usually need --no-sandbox --disable-dev-shm-usage).
  • cua-driver for the desktop surface, and a Windows session that is actually on the interactive desktop.
  • No credentials for the core, the browser surface, the mock chooser or the tests.

Status

building, released as 0.1.0 (alpha). Started 2026-09-18. What changed is in CHANGELOG.md; what has been verified, and what has not, is in docs/findings.md. Roadmap items live as issues rather than in this file.

Where things live

  • src/jev_pilot/: the library (loop, policy, perception, safety, traces, bench, CLI, surfaces).
  • tests/: over 170 credential-free tests, plus one live test that drives a real headless Chrome. The count is a floor on purpose: an exact number in a README drifts with every new test, and a stale number is a wrong claim.
  • docs/: architecture, perception, decisions, cookbook, findings, parity with the wider ecosystem effort, publishing, evidence for the quoted runs, and a decision log that records which calls were made by a model and which by hand.
  • examples/: a task file, a reference bench fixture, and runnable examples.
  • .github/workflows/: CI (credential-free), the release lane, and an optional manual live lane.

Contributing

See CONTRIBUTING.md. The short version: the default test suite must pass with no credentials and no network, a bug fix comes with a test that fails before it, and a performance or accuracy claim comes with the command that produced it.

License

MIT. See LICENSE.