this week
How to Build a Fast Jev Browser Agent with Playwright and Kernel
Build a Jev browser agent with Playwright and Kernel, including typed actions, verification, escalation, browser pools, and cost analysis.
TL;DR
Jev is TypeSafe AI's first "System One" model. It takes text state and typed questions and returns typed answers with probabilities, in 70–500 ms per call. It does not generate free text and does not accept images.
In a browser agent, that makes Jev a good fit for one job: choosing the next action at each step. A working Jev browser agent has three parts:
- Jev decides. One call per step picks the action and target element, and estimates whether the goal is met or the agent is stuck.
- A small language model types. Jev can't produce strings, so form input goes to a separate model.
- A browser executes. Playwright drives a real Chromium instance. This guide uses a Kernel cloud browser.
Best for: high-volume, DOM-readable workflows where per-step latency and cost matter (navigation, search, filtering, simple forms).
What is Jev?
Jev is a text-only decision model from TypeSafe AI. In browser agents, it selects the next action from a predefined schema and returns probabilities, while Playwright or another browser tool executes the action.
It returns decisions, not text. TypeSafe describes it as "unstructured state in, typed probabilistic decisions out". Every request pairs a state (strings, JSON objects, or arrays of text) with one or more questions, and each question is one of three primitives.
| Primitive | Returns | Browser-agent job |
|---|---|---|
| Choice | One option from a fixed set, plus probabilities and confidence | Pick the next action (click, type, scroll, done) or the target element |
| Noul | Probability (0–1) that a statement is true | "Is the goal met?" "Is the agent stuck?" "Is this a login wall?" |
| Score | A level on an ordered scale, up to 10 levels | Rank how relevant a result or page is |
Because answers are restricted to the schema you define, Jev can't return an action or element that doesn't exist. It can still pick the wrong one. The probabilities are what let your code decide when to act and when to escalate.
Jev's published specs and limits
All vendor-published by TypeSafe.
| Attribute | Value |
|---|---|
| End-to-end latency | 70–500 ms per call |
| Input price | $0.042 per million tokens ($42 per billion) |
| Output price | Free |
| Rate limits | 250,000 tokens/second and 1,200 requests/minute |
| Context length | 64k tokens per request; 32k for state plus the longest question |
| Max options per Choice | 255 |
| Input types | Text and JSON only — no images, audio, or video |
| Current version | jev-1.13.0, aliased as jev-latest |
| Availability | Early access with a waitlist, launched Sept 15, 2026 |
Four of these shape the design:
- Text-only input means the agent reads the page through the DOM, not screenshots.
- The 255-option cap means a busy page needs its elements filtered or split across Choice questions.
- The 32k state budget caps how much page text and how many element labels one call can carry.
- 1,200 requests per minute is the real ceiling on concurrency. At one Jev call per step, that is roughly 20 agent steps per second across your whole account, regardless of how many browsers you run. Higher limits are available on custom and enterprise plans, and TypeSafe notes the published limits are moving while it scales.
Jev versus the other ways to pick the next action
Most browser agents use one model for everything. Splitting the job by capability is what makes the loop below fast.
| Jev (System One) | Small LM | Frontier / computer-use model | |
|---|---|---|---|
| Accepts screenshots | No | No | Yes |
| Output | Typed decision + calibrated probabilities | Free text | Free text and tool calls |
| Can return an action that doesn't exist | No — answers are constrained to your schema | Yes | Yes |
| Plans several steps ahead | No — chooses the next step from current state | No | Yes |
| Writes field text | No | Yes | Yes |
| Best role in a browser loop | The default per-step decision | Writes the string when the action is "type" | Escalation path and planning |
The point of the split is that the expensive model only runs on the steps that need it.
How does Jev control a browser?
It doesn't — not directly. Jev has no browser integration of its own. Your code turns the page into text, asks Jev which action to take, and a browser library executes that action. Each step makes one Jev call over a text snapshot of the page, runs the chosen action, and checks whether to stop.
The decide → act → verify loop.
The Kernel browser's page state becomes a numbered element table; one Jev call over that table returns the action, a goal probability, and a stuck probability; an actionable result goes to Playwright, which executes it and loops back to a fresh page state, calling a small LM first if the action is "type"; a stuck result or an exhausted budget escalates to a frontier model or a person; a goal-met result goes to independent verification rather than straight to success.
The open-source project jev-browser uses the same pattern against local Chromium. This guide runs the loop against a cloud browser so it can scale past one machine and handle logins and bot detection.
How fast is a Jev browser agent?
Two public projects have published timings for this pattern on local Chromium, and both state their own caveats.
jev-browser navigated Wikipedia from the Coffee article to Espresso "in about 4 seconds, for $0.0016." That figure is the Jev model cost only: the README's own trace shows a three-call run at 51,748 input tokens costing an estimated $0.0022, which is exactly 51,748 × $0.042/MTok. Browser time and the small-LM call are excluded. Its 0.85 goal and stuck thresholds are described as "starting points measured on Wikipedia and DuckDuckGo tasks."
jev-ultrafast published a per-run breakdown of a Google Flights search (Zürich → London):
| Measurement | Value |
|---|---|
| Total verified task time | 7.073 s |
| Median Jev latency | 178 ms |
| Jev requests in the run | 17 |
| Interactions plus one explicit wait | 11 |
| TypeSafe input tokens across the run | 90,558 |
| Small-LM cost (two calls, OpenRouter) | $0.00006272 |
| Browser protocol calls, before → after optimization | 1,092 → 101 |
| Median task time, before → after | 9.450 s → 7.092 s (25% lower) |
Two other tasks on the same policy: a Wikipedia article opened in 2.798 s, and a local hotel search-and-filter in 1.896 s. The project states the comparison is "three repeats of one task on one browser profile, not a general reliability benchmark," with a two-sided sign-test p of 0.25.
The number to carry forward: a median Jev call of 178 ms inside a 7-second task means the model is not the bottleneck. Page loads, browser startup, bot detection, and logins are. Everything after the build steps is about that part.
Prerequisites
- TypeSafe API key. Jev is in early access, so join the waitlist at console.typesafe.ai first. Set it as
TYPESAFE_API_KEY(Python SDK). - Kernel API key. Set it as
KERNEL_API_KEY, which the SDK reads by default. - A small LM for text input. Any fast, cheap chat model works. This guide uses an OpenAI-compatible client as a placeholder.
- Python 3.10+ and these packages:
pip install typesafe-sdk kernel playwright openaiPlaywright doesn't need to download a local browser, because it connects to a Kernel browser over CDP.
Step-by-step: build the agent
The full agent comes to about 170 lines of Python. Each step names what it depends on, so you can read any one of them on its own.
Step 1: Start a Kernel browser and connect Playwright
Depends on: nothing. This is the entry point.
Kernel returns a CDP WebSocket URL, and Playwright connects to it like a local browser. The browser_live_view_url lets you watch the agent while it runs. Use AsyncKernel rather than the sync client, so the API calls don't block the event loop.
import asyncio
from kernel import AsyncKernel
from playwright.async_api import async_playwright
kernel = AsyncKernel() # reads KERNEL_API_KEY
async def open_browser(pw):
kb = await kernel.browsers.create(stealth=True, timeout_seconds=1800)
browser = await pw.chromium.connect_over_cdp(kb.cdp_ws_url)
context = browser.contexts[0]
page = context.pages[0] if context.pages else await context.new_page()
print("Live view:", kb.browser_live_view_url)
return kb, browser, pageStep 2: Turn the page into an element table
Depends on: a Playwright page from Step 1.
Jev reads text, not screenshots, so the agent describes the page as a numbered list of visible controls. Each control gets a data-agent-id attribute so Playwright can find it again. The extractor clears the previous step's attributes first — without that, controls that dropped out of the selector keep a stale id, and page.locator('[data-agent-id="5"]') can match two nodes and trip Playwright's strict mode.
The list stops at 240 controls to leave room under the 255-option cap for scroll, back, and done. Page text is capped at 4,000 characters to stay inside the 32k state budget.
EXTRACT_JS = """
() => {
for (const stale of document.querySelectorAll('[data-agent-id]')) {
stale.removeAttribute('data-agent-id');
}
const sel = 'a[href], button, input, select, textarea, [role=button], [role=link], [role=tab], [role=menuitem]';
const out = [];
for (const el of document.querySelectorAll(sel)) {
if (out.length >= 240) break;
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
if (!r.width || !r.height || s.visibility === 'hidden' || s.display === 'none') continue;
const id = out.length;
el.setAttribute('data-agent-id', String(id));
const tag = el.tagName.toLowerCase();
const textInput = tag === 'textarea' ||
(tag === 'input' && !['submit', 'button', 'checkbox', 'radio'].includes(el.type));
const kind = textInput ? 'type' : tag === 'select' ? 'select' : 'click';
const label = (el.getAttribute('aria-label') || el.innerText || el.placeholder || el.value || '')
.trim().slice(0, 80);
out.push({ id, kind, tag, label });
}
return out;
}
"""
async def observe(page, task, history):
elements = await page.evaluate(EXTRACT_JS)
state = {
"task": task,
"url": page.url,
"title": await page.title(),
"page_text": (await page.inner_text("body"))[:4000],
"recent_actions": history[-5:],
}
return elements, stateThis extractor is intentionally minimal. It skips shadow DOM and iframes, the same gaps the open-source Jev agents list. Production pages may need both.
Step 3: Ask Jev three questions in one call
Depends on: the elements list from Step 2.
One Choice picks the action and its target together. Two Nouls check whether the goal is met and whether the agent is stuck. All three run in parallel in a single request, so this costs one round trip, not three.
from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul
def build_questions(elements):
criteria = {f"{el['kind']}_{el['id']}": f"{el['kind']} {el['tag']} '{el['label']}'" for el in elements}
criteria.update({
"scroll_down": "Scroll down to reveal more of the page",
"back": "Go back to the previous page",
"done": "The task in the state is complete",
})
return {
"action": Choice(instructions="Which single action best advances the task?", criteria=criteria),
"goal_met": Noul(instructions="Is the task complete on the current page?"),
"stuck": Noul(instructions="Is the agent looping or blocked by a captcha, login wall, or error page?"),
}Step 4: Execute the action (and hand typing to a small LM)
Depends on: the page, elements, and state from Step 2, the chosen action string from Step 3, and an open jev client.
Clicks and scrolls go straight to Playwright. A type_ action calls a small LM for the string. A select_ action makes a second Jev Choice over the dropdown's options.
from openai import AsyncOpenAI
llm = AsyncOpenAI()
SMALL_MODEL = "your-small-model" # any fast, cheap chat model
async def write_text(task, element):
r = await llm.chat.completions.create(
model=SMALL_MODEL,
max_tokens=50,
messages=[{"role": "user", "content":
f"Task: {task}\nField: {element['label']}\nReply with only the text to type."}],
)
return r.choices[0].message.content.strip()
async def execute(page, action, task, elements, state, jev):
if action == "scroll_down":
await page.mouse.wheel(0, 800)
elif action == "back":
await page.go_back()
else:
kind, idx = action.split("_", 1)
target = page.locator(f'[data-agent-id="{idx}"]')
if kind == "click":
await target.click(timeout=5000)
elif kind == "type":
await target.fill(await write_text(task, elements[int(idx)]))
await target.press("Enter") # submits search boxes; drop for multi-field forms
elif kind == "select":
options = await target.locator("option").all_inner_texts()
r = await jev.system_one(state=state, questions={
"option": Choice(instructions="Which option fits the task?",
criteria={o: None for o in options[:255]}),
})
await target.select_option(label=r.choices["option"].choice)
await page.wait_for_load_state("domcontentloaded")Step 5: Run the loop with stop conditions
Depends on: observe (Step 2), build_questions (Step 3), execute (Step 4), and verify (Step 7).
The loop stops on three conditions: Jev picks done or the goal probability crosses a threshold, the stuck probability crosses a threshold, or the step budget runs out. The 0.85 thresholds match jev-browser's defaults; tune them against your own tasks.
describe is what makes the action history useful. Element ids are reassigned on every observation, so logging the raw choice would put click_12 in the state on two steps to mean two different elements.
GOAL_THRESHOLD = 0.85
STUCK_THRESHOLD = 0.85
MIN_CONFIDENCE = None # see Step 6
MAX_STEPS = 25
def describe(action, elements):
for el in elements:
if f"{el['kind']}_{el['id']}" == action:
return f"{el['kind']} '{el['label']}'"
return action
async def agent_loop(page, task, jev, check):
history = []
for _ in range(MAX_STEPS):
elements, state = await observe(page, task, history)
r = await jev.system_one(state=state, questions=build_questions(elements))
action = r.choices["action"]
if action.choice == "done" or r.nouls["goal_met"].noul >= GOAL_THRESHOLD:
return await verify(page, check)
if r.nouls["stuck"].noul >= STUCK_THRESHOLD:
return {"status": "escalate", "reason": "stuck"}
if MIN_CONFIDENCE is not None and action.confidence < MIN_CONFIDENCE:
return {"status": "escalate", "reason": f"low confidence on {action.choice}"}
await execute(page, action.choice, task, elements, state, jev)
history.append(describe(action.choice, elements))
return {"status": "escalate", "reason": "step budget"}Step 6: When should a browser agent escalate from Jev?
Depends on: agent_loop (Step 5) and open_browser (Step 1).
Escalate on three signals: the stuck Noul crosses its threshold, the step or time budget runs out, or independent verification fails. On escalation the Kernel browser stays alive, so a frontier or computer-use model can connect to the same cdp_ws_url and continue from the current page, or a person can take over through the live view.
async def run(task, start_url, check):
async with async_playwright() as pw, AsyncTypeSafeClient() as jev:
kb, browser, page = await open_browser(pw)
await page.goto(start_url)
result = await agent_loop(page, task, jev, check)
if result["status"] == "escalate":
# Hand the live session to a System 2 model or a person.
result.update(session_id=kb.session_id,
cdp_ws_url=kb.cdp_ws_url,
live_view=kb.browser_live_view_url)
else:
await kernel.browsers.delete_by_id(kb.session_id)
return resultLow Choice confidence is the tempting fourth signal, and it is the one to be careful with. Confidence summarizes how peaked the probability distribution is, not whether the answer is right. A page with eight near-identical result links spreads probability across eight acceptable options, so a blanket gate escalates every time — jev-browser omits the gate for exactly this reason. MIN_CONFIDENCE is left at None above; turn it on only once your own traces show that low confidence predicts failure on your pages.
Step 7: Verify "done" independently
Depends on: the page from Step 1 and a check callable you supply.
A high goal probability means Jev thinks the task is complete. As jev-ultrafast puts it, "DONE is never independent evidence of success." Use a deterministic check: a URL pattern, a selector, or a value on the page.
async def verify(page, check):
ok = await check(page)
return {"status": "success" if ok else "escalate",
"reason": None if ok else "verification failed",
"url": page.url}from urllib.parse import urlsplit
async def reached_espresso(page):
url = urlsplit(page.url)
return (
url.scheme == "https" and url.hostname == "en.wikipedia.org" and url.path == "/wiki/Espresso"
)
if __name__ == "__main__":
print(asyncio.run(run(
task="Starting from the Coffee article, open the Wikipedia article about espresso.",
start_url="https://en.wikipedia.org/wiki/Coffee",
check=reached_espresso,
)))Production hardening on Kernel
The loop above works on one page for one task. Running it across many tasks, sites, and logged-in accounts depends on the browser layer. Each row maps to a failure mode that shows up once the model step is fast.
| Problem | Kernel feature | What changes in the code |
|---|---|---|
| Browser startup adds latency to every task | Browser pools keep pre-started browsers ready | Call browser_pools.acquire() instead of browsers.create() |
| Fast agents hit bot detection sooner | Stealth mode and proxies | stealth=True on the browser or pool |
| Tasks behind a login | Profiles save cookies and storage; managed auth handles the login and reauth itself | Attach a profile or an auth connection at browser creation |
| Debugging a wrong decision after the fact | Replays record the session as MP4 | browsers.replays.start() / .stop() around the loop |
| Handing off to a person or System 2 model | Live view and the same CDP URL | Return browser_live_view_url on escalation (Step 6) |
| Paying for time the agent spends waiting | Standby pauses billing after 5 seconds of inactivity | Disconnect the CDP client while the session idles — a connected client counts as activity |
That last row is the one people get wrong. Kernel treats a browser as active while any CDP, WebDriver, or live view client is connected, so an agent that holds its Playwright connection open never reaches standby. The saving shows up on sessions parked awaiting escalation, and only after you disconnect.
Size against both ceilings: concurrent browsers are capped per plan (5 on Developer, 10 on Hobbyist, 150 on Start-Up), on top of Jev's account-wide request limit.
Swap on-demand browsers for a pool
Pool acquisition skips browser startup and avoids the rate limit on creating browsers. For reference, Kernel publishes on-demand creation latency of 30 ms at P50 and 105 ms at P99, last reported April 24, 2026. An acquired browser returns the same fields as a created one, so the rest of the agent doesn't change.
async def setup_pool(): # one-time
await kernel.browser_pools.create(name="jev-agents", size=10, stealth=True, timeout_seconds=600)
async def open_browser(pw):
kb = await kernel.browser_pools.acquire("jev-agents", acquire_timeout_seconds=30)
browser = await pw.chromium.connect_over_cdp(kb.cdp_ws_url)
context = browser.contexts[0]
page = context.pages[0] if context.pages else await context.new_page()
return kb, browser, page
# When the task finishes, return the browser instead of deleting it:
# await kernel.browser_pools.release("jev-agents", session_id=kb.session_id, reuse=True)Release with reuse=False if different end users share the pool — reuse=True hands the next caller the previous session's cookies and open tabs.
Record every run
When Jev picks the wrong element with high confidence, the probabilities alone won't show why. A replay shows what the page looked like at that step.
async def run_with_replay(kb, page, task, jev, check):
replay = await kernel.browsers.replays.start(kb.session_id)
try:
return await agent_loop(page, task, jev, check)
finally:
await kernel.browsers.replays.stop(replay_id=replay.replay_id, id_or_name=kb.session_id)Log Jev's full answer at each step — the chosen action, its confidence, and both Noul values — next to the replay ID.
Headless or headful
Kernel publishes browser usage per second: $0.0001333336 headful and $0.0000166667 headless. The gap is memory — headful browsers run on 8 GB and headless on 1 GB — so both rates are the same underlying $0.0000166667 per GB-second. Headful, non-GPU browsers can be raised to 16 GiB for tab-heavy work.
Headful adds live view, replays, and stronger stealth. Headless is cheaper and fine for sites without bot detection, though Kernel notes some bot detectors flag headless mode.
The decision rule
Use Jev for DOM-readable, repetitive steps where per-step latency and cost matter. Escalate to a frontier or computer-use model when independent verification fails, the step or time budget expires, or the page requires visual reasoning. Keep the browser alive across that handoff so the second model resumes on the same page rather than starting over.
What a Jev browser agent costs per task
At Kernel's published rates, a 4-second session costs $0.00053 headful (4 s × $0.0001333336) or $0.000067 headless (4 s × $0.0000166667). Add jev-browser's third-party measured $0.0016 in Jev cost for a comparable Wikipedia task, and the whole thing is a fraction of a cent.
What matters more at volume is how long each task holds a browser. A task that spends 4 seconds deciding and 40 seconds waiting on page loads and escalation costs ten times as much in browser time — which is why pools, standby, and disconnecting an idle session matter more than shaving milliseconds off the model call.
Limitations and when not to use Jev
Jev is weakest where the page can't be summarized as text or the next step needs open-ended reasoning.
| Situation | Why it's hard for Jev | Better option |
|---|---|---|
| Canvas apps, maps, charts, image-only buttons | No image input; the DOM has little to read | Computer-use model reading screenshots |
| Shadow DOM, iframes, pop-up tabs, nested scroll areas, file uploads | Listed as unsupported by the open-source agents; the extractor here skips them too | Extend the extractor, or escalate |
| Pages with more than 255 relevant controls | Choice caps at 255 options | Filter by viewport or section, or split across questions |
| Pages whose text blows past the state budget | 32k tokens covers state plus the longest question | Truncate page text and label length, as the extractor does |
| Multi-field forms needing generated text | Jev can't write strings | Small LM per field, or a frontier model for the whole form |
| Tasks that need planning over many steps | Jev chooses the next step from current state | Frontier model plans; Jev executes each step |
| Sustained throughput above ~20 steps/second | 1,200 requests per minute, account-wide | Custom or enterprise limits from TypeSafe |
| Production workloads today | Early access with a waitlist | Keep a frontier-model fallback path |
If you are still choosing the browser layer under all of this, compare the options in the best browsers for AI agents in 2026, or see how the loop above fits alongside the top AI agent frameworks.
FAQ
Can Jev control a browser by itself?
No — Jev returns a typed decision and nothing else. A browser library executes it.
Does Jev work with Playwright?
Yes, indirectly. Playwright executes the actions Jev picks, driving either local Chromium or a cloud browser such as Kernel over CDP.
Can Jev read screenshots?
Not currently. Jev's state accepts strings, JSON objects, and arrays of text only, so browser agents built on Jev read the DOM.
How much does a Jev browser agent cost per task?
jev-browser reports $0.0016 in Jev cost for a short Wikipedia task, excluding browser time and the small-LM call. On Kernel, add $0.00053 for a 4-second headful session or $0.000067 headless.
How fast is a single Jev call in a real browser agent?
TypeSafe publishes 70–500 ms end to end. In jev-ultrafast's recorded run, median Jev latency was 178 ms across 17 requests in a 7.073-second task.
Does Jev hallucinate actions?
It can't return an action outside the options you give it. It can still pick the wrong valid option, which is why Step 7 verifies completion rather than trusting a done.
Should I escalate when Jev's confidence is low?
Usually not on its own — several genuinely acceptable options spread the distribution and look like doubt. Escalate on the stuck signal, a budget, or a failed verification instead.
How is a Jev agent different from a frontier-model browser agent?
A frontier-model agent generates its next action as text and can reason over screenshots; a Jev agent picks from a fixed list in 70–500 ms. Use Jev as the default and the frontier model as the escalation path.
Why use a cloud browser instead of local Chromium?
A cloud browser like Kernel adds concurrency, pre-started pools, saved logins, stealth and proxies, session recordings, and a live view for handing off to a person.
How many Jev browser agents can I run at once on Kernel?
Concurrency is capped per plan: 5 browsers on Developer, 10 on Hobbyist, 150 on Start-Up. Reserved pool capacity counts against that limit whether or not the browsers are acquired. The second ceiling is Jev's 1,200 requests per minute, which caps total agent steps per second across the account no matter how many browsers you have.
How long does the Kernel browser stay alive after the agent escalates?
Not long, by default. A browser's timeout_seconds clock starts when it enters standby — five seconds after the last CDP, WebDriver, or live view client disconnects — and the default is 60 seconds. If you're handing a session to a frontier model or a person, set timeout_seconds explicitly at creation; Kernel allows up to 72 hours.
Can I run a Jev browser agent outside the US?
Yes, on Kernel’s Start-Up and Enterprise plans. Pass region as us-east (the default), eu-west, or ap-southeast when creating a browser or a pool, at the same usage rates. Regional browsers support pools and managed auth; put the agent process near the browser, since the network hop between them is unmeasured in this guide's numbers.