Kernel

this week

AI ArticlesKernel Team

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:

  1. Jev decides. One call per step picks the action and target element, and estimates whether the goal is met or the agent is stuck.
  2. A small language model types. Jev can't produce strings, so form input goes to a separate model.
  3. 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.

PrimitiveReturnsBrowser-agent job
ChoiceOne option from a fixed set, plus probabilities and confidencePick the next action (click, type, scroll, done) or the target element
NoulProbability (0–1) that a statement is true"Is the goal met?" "Is the agent stuck?" "Is this a login wall?"
ScoreA level on an ordered scale, up to 10 levelsRank 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.

AttributeValue
End-to-end latency70–500 ms per call
Input price$0.042 per million tokens ($42 per billion)
Output priceFree
Rate limits250,000 tokens/second and 1,200 requests/minute
Context length64k tokens per request; 32k for state plus the longest question
Max options per Choice255
Input typesText and JSON only — no images, audio, or video
Current versionjev-1.13.0, aliased as jev-latest
AvailabilityEarly 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 LMFrontier / computer-use model
Accepts screenshotsNoNoYes
OutputTyped decision + calibrated probabilitiesFree textFree text and tool calls
Can return an action that doesn't existNo — answers are constrained to your schemaYesYes
Plans several steps aheadNo — chooses the next step from current stateNoYes
Writes field textNoYesYes
Best role in a browser loopThe default per-step decisionWrites 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):

MeasurementValue
Total verified task time7.073 s
Median Jev latency178 ms
Jev requests in the run17
Interactions plus one explicit wait11
TypeSafe input tokens across the run90,558
Small-LM cost (two calls, OpenRouter)$0.00006272
Browser protocol calls, before → after optimization1,092 → 101
Median task time, before → after9.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 openai

Playwright 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, page

Step 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, state

This 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 result

Low 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.

ProblemKernel featureWhat changes in the code
Browser startup adds latency to every taskBrowser pools keep pre-started browsers readyCall browser_pools.acquire() instead of browsers.create()
Fast agents hit bot detection soonerStealth mode and proxiesstealth=True on the browser or pool
Tasks behind a loginProfiles save cookies and storage; managed auth handles the login and reauth itselfAttach a profile or an auth connection at browser creation
Debugging a wrong decision after the factReplays record the session as MP4browsers.replays.start() / .stop() around the loop
Handing off to a person or System 2 modelLive view and the same CDP URLReturn browser_live_view_url on escalation (Step 6)
Paying for time the agent spends waitingStandby pauses billing after 5 seconds of inactivityDisconnect 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.

SituationWhy it's hard for JevBetter option
Canvas apps, maps, charts, image-only buttonsNo image input; the DOM has little to readComputer-use model reading screenshots
Shadow DOM, iframes, pop-up tabs, nested scroll areas, file uploadsListed as unsupported by the open-source agents; the extractor here skips them tooExtend the extractor, or escalate
Pages with more than 255 relevant controlsChoice caps at 255 optionsFilter by viewport or section, or split across questions
Pages whose text blows past the state budget32k tokens covers state plus the longest questionTruncate page text and label length, as the extractor does
Multi-field forms needing generated textJev can't write stringsSmall LM per field, or a frontier model for the whole form
Tasks that need planning over many stepsJev chooses the next step from current stateFrontier model plans; Jev executes each step
Sustained throughput above ~20 steps/second1,200 requests per minute, account-wideCustom or enterprise limits from TypeSafe
Production workloads todayEarly access with a waitlistKeep 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.

more articles

view all