Skip to main content
Kernel’s stealth mode includes an automatic captcha solver that attempts supported challenges — reCAPTCHA, hCaptcha, Cloudflare/Turnstile, and press-and-hold — without any action from your agent. The solver runs in the VM; your agent’s job is to not get in its way, and to know what happened when it finishes. The usual approach is a system-prompt instruction: "If you see a CAPTCHA or similar test, just wait for it to get solved automatically." That works, but the model decides for itself how long to wait and how to tell a solve is still running, from a screenshot alone. Captcha telemetry gives you the signal directly. This cookbook wires it into a @onkernel/browser-loop agent so that browser actions are held while a solve is outstanding, and the agent is told the outcome in terms it can act on. The design follows one split: telemetry decides what to say, while the live page decides whether to interrupt the agent at all. A solver task succeeding and a challenge clearing are different facts, so the gate never reports one as the other.

What the events tell you

Four rules from Correlate captcha tasks and challenges shape everything below:
  • Use the identifier the result carries. Pair a start with its task result on task_id. An observed challenge result joins on challenge_id when the task carries it; an inferred result has inferred: true and joins on task_id instead.
  • A task result isn’t always the end of a challenge. task_kind: token returns an answer for the whole widget, while image_challenge completes only one image round. On older events without task_kind, the gate falls back to captcha_type where it can and leaves ambiguous tasks unclassified.
  • Delivery is best-effort and unordered. A start can arrive after its result, and any event can be absent. Nothing may depend on arrival order, and every wait needs a deadline.
  • Fall back to the page. When you need a challenge-level outcome and don’t have one, use the available task results and the current page state.

Setup

Pin @earendil-works/pi-agent-core to the version @onkernel/browser-loop depends on. A newer one renames the exports this script uses and the script won’t compile.
@earendil-works/pi-agent-core ships ESM only. Without type: module in package.json, tsx resolves captcha-gate.ts’s import of it as CommonJS and fails immediately with ERR_PACKAGE_PATH_NOT_EXPORTED. Every run needs a KERNEL_API_KEY and a provider key for whichever model LOOP_MODEL points at (anthropic:claude-sonnet-5 by default, so ANTHROPIC_API_KEY):

Build the gate

The four snippets below are one file, captcha-gate.ts, in order.
1

Read the events

The gate records task and challenge events by ID, so delivery order doesn’t matter. It joins inferred results on task_id and observed results on challenge_id.A task record created by a result already has a status, so a captcha_solve_started that arrives afterwards finds a closed task and leaves it closed. That only works while the task is still in tasks, though — once reset has cleared it, a late start for the same task_id would otherwise look like a brand-new one. settledAt remembers when reset cleared each ID, so a late arrival within TASK_SETTLE_MS of that is dropped instead of reopening an episode the agent has already been told about. Past that window the same ID is treated as fresh again, since Kernel can reuse a challenge_id when an episode continues across a page reload.The stream’s AbortController is saved so the gate can close it later — the loop runs detached from the caller, and leaving it open past the browser’s deletion keeps the process alive waiting on a connection that will never produce another event.
captcha-gate.ts
2

Decide when to hold

A task counts as open while it has no terminal status and is still inside its deadline — that deadline is what stops a missing captcha_solve_result from holding the agent forever. until is the only waiting primitive, and it always takes a timeout.
captcha-gate.ts
3

Resolve an outcome

resolve waits briefly for a challenge result when one can follow the task, then reads the page. It prefers a joined challenge result, then an unjoined result, a task result, or the page alone. Known image rounds and Turnstile tokens don’t trigger the extra wait. An observed reCAPTCHA result can be used without joining it to a task.holding and pending are separate on purpose. pending covers a terminal outcome that lands while no action is in flight — without it, a challenge result arriving between tool calls is recorded and never told to anyone. It has to catch every settled task, not just failures: a successful task-only result (Turnstile’s usual path) and a task whose deadline lapsed with no result at all are both settled outcomes the agent needs to hear about.reset only clears a task once something actually covers it — a terminal status of its own, or a challenge result for the episode it belongs to. A task the solver hasn’t finished with survives, so holding stays true and the next tool call keeps waiting on it instead of losing track of a solve that’s still in progress.
captcha-gate.ts
4

Wire it to the agent

harness.on("tool_call", …) is awaited before the tool is dispatched, so returning from it late holds the action and returning { block: true, reason } replaces it with a message the model reads. That is cheaper than aborting the turn and it stops the action before it reaches the page.The page probe is a read-only Playwright snippet. Its result is what decides whether the agent is interrupted at all — a cleared page just resumes with no extra turn. That decision checks tokenPresent, not just widgets: a solved reCAPTCHA or hCaptcha widget commonly stays rendered on the page, so a response token is the signal that the challenge is actually behind the agent, not the widget disappearing.gate.close() runs before the browser is deleted, so the detached telemetry loop from step one gets torn down instead of hanging on a connection to a session that no longer exists.The gate only speaks up when telemetry gives it something to report. Since any event can be absent, the system prompt keeps a fallback instruction for the case where nothing ever arrives — the model still knows to wait out a captcha it can see but the gate never heard about.
captcha-gate.ts
captcha-gate.ts

What the agent is told

Every verdict pairs a telemetry claim with the page state it was checked against: Durations come straight from each event’s duration_ms, which is authoritative; don’t compute them from event timestamps.

Limits

  • TASK_SETTLE_MS and CHALLENGE_GRACE_MS are the safety net. Events can be absent, so both waits are bounded and the gate falls through to the page rather than stalling. Raise TASK_SETTLE_MS if your solves routinely run longer than 20s.
  • Challenge results aren’t emitted for every widget type. Turnstile reports task events only, so the task-plus-page path is the one that runs there. An inferred result for another provider reports a returned token, not an observed page clear.
  • One episode at a time. The gate resets after each verdict; overlapping visible challenges from the same provider aren’t split apart, and the telemetry docs note Kernel’s own event model can’t always attribute a result in that case either.
  • The page probe is best-effort. It reads the DOM for known widget markers and a response token. A site that renders its challenge somewhere those selectors miss won’t be held past the logged message alone.
  • GeeTest has no selector either. The solver reports geetest task events, but PROBE’s groups has no GeeTest entry and tokenPresent checks no GeeTest field, so widgets never reports one and the agent proceeds as soon as the task settles, even while the challenge is still open. Add a selector for your target’s GeeTest integration if you need it to hold.
  • hCaptcha often takes several rounds. hCaptcha challenges are hard, so the solver often fails a round and retries with a new one before the challenge clears. Each round is its own image_challenge task, so a finished or failed round doesn’t mean the solve is over. Wait until the solver is done retrying, signaled by the captcha_challenge_result for that challenge_id (solved or abandoned), before letting the agent act on the page. If the agent is told about a single image round, have it keep waiting rather than interact with the widget.
  • Press-and-hold has no selector at all. It emits captcha_challenge_result telemetry like reCAPTCHA and hCaptcha, but PROBE’s groups has no entry for it, so widgets never reports one — the tool-call handler treats that as clear and lets the agent proceed even while the challenge is still open. Add a selector for your target’s specific implementation if you need it to actually hold.
  • A reload within TASK_SETTLE_MS of the last verdict can still be missed. settledAt treats an ID as fresh again once that window passes, so a genuinely new outcome for a reused challenge_id eventually gets through — but if the reload happens sooner than that, its result is indistinguishable from a stale duplicate and gets dropped.

Next steps