> ## Documentation Index
> Fetch the complete documentation index at: https://www.kernel.sh/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Pausing for Captcha Solves

> Use captcha telemetry to hold an agent while Kernel's solver works, and tell it what actually happened

Kernel's [stealth mode](https://www.kernel.sh/docs/browsers/bot-detection/stealth.md) 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."`](https://www.kernel.sh/docs/browsers/bot-detection/stealth#anthropic-computer-use) 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](https://www.kernel.sh/docs/browsers/telemetry/categories.md#correlate-captcha-tasks-and-challenges) gives you the signal directly. This cookbook wires it into a [`@onkernel/browser-loop`](https://www.npmjs.com/package/@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

| Event | Scope | What it means |
| - | - | - |
| `captcha_solve_started` | Solver task | The solver accepted a task. It does **not** mean a solve is currently in flight. |
| `captcha_solve_result` | Solver task | A task ended `success`, `failure`, `timeout`, or `abandoned`. Success means the solver returned a usable answer, not that the challenge cleared. |
| `captcha_challenge_result` | Visible challenge or inferred token result | An observed challenge reached its overall outcome, or a successful token task produced an inferred result without page observation. |

Four rules from [Correlate captcha tasks and challenges](https://www.kernel.sh/docs/browsers/telemetry/categories.md#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

> [!WARNING]
>
> 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.

```bash theme={null}
npm install @onkernel/browser-loop @onkernel/sdk tsx
npm install @earendil-works/pi-agent-core@0.83.0 --save-exact
npm pkg set type=module
```

`@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`):

```bash theme={null}
KERNEL_API_KEY=... ANTHROPIC_API_KEY=... npx tsx captcha-gate.ts "your task"
```

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

```ts captcha-gate.ts theme={null}
/**
 * Pause a browser-loop agent while Kernel's captcha solver works, and tell it
 * what actually happened when the solve ends.
 *
 * Usage:
 *   KERNEL_API_KEY=... ANTHROPIC_API_KEY=... npx tsx captcha-gate.ts "your task"
 */
import KERNEL from "@onkernel/sdk";
import { AgentHarness, InMemorySessionRepo } from "@earendil-works/pi-agent-core";
import { loop } from "@onkernel/browser-loop";
import { attach, requireLoopEnvApiKeyForModel, type LoopModelRef } from "@onkernel/browser-loop/pi";

const TASK_SETTLE_MS = 20_000;
const CHALLENGE_GRACE_MS = 8_000;
const POLL_MS = 250;

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

interface PageState {
	widgets: string[];
	tokenPresent: boolean;
}

interface Outcome {
	status?: string;
	durationMs?: number;
	captchaProvider?: string;
	taskKind?: string;
	challengeId?: string;
	inferred?: boolean;
}

interface Verdict extends Outcome {
	source: "challenge" | "task" | "page";
	status: string;
	joined: boolean;
	page: PageState;
	message: string;
}

function createCaptchaGate(kernel: KERNEL, sessionId: string, probePage: () => Promise<PageState>) {
	const tasks = new Map<string, Outcome & { openedAt: number }>();
	const challenges = new Map<string, Outcome & { status: string }>();
	const joinable = new Set<string>();
	// Task and challenge keys reported and cleared by reset(), each stamped
	// with when that happened. A late-arriving duplicate within TASK_SETTLE_MS
	// of that -- delivery is unordered, a start can arrive after its own
	// result -- is dropped instead of reopening an already-told episode. Past
	// that window it's treated as fresh, since Kernel can reuse the same
	// challenge_id when an episode continues across a page reload.
	const settledAt = new Map<string, number>();
	const taskKey = (id: string) => `task:${id}`;
	const challengeKey = (id: string) => `challenge:${id}`;
	const providerOf = (data: { captcha_provider?: string; captcha_type?: string }) =>
		data.captcha_provider ?? (data.captcha_type === "press_and_hold" ? "human" : data.captcha_type);
	const kindOf = (data: { task_kind?: string; captcha_type?: string }) => {
		if (data.task_kind) return data.task_kind;
		if (data.captcha_type === "press_and_hold") return "press_and_hold";
		if (["turnstile", "geetest", "recaptcha_v3"].includes(data.captcha_type ?? "")) return "token";
	};
	const isSettled = (id: string) => {
		const at = settledAt.get(id);
		return at !== undefined && Date.now() - at < TASK_SETTLE_MS;
	};
	let streamController: AbortController | undefined;

	void (async () => {
		const stream = await kernel.browsers.telemetry.stream(sessionId);
		streamController = stream.controller;
		for await (const { event } of stream) {
			if (event.category !== "captcha") continue;

			if (event.type === "captcha_solve_started" && event.data?.task_id && !isSettled(taskKey(event.data.task_id))) {
				// A result for an unseen task lands already closed, so a start that
				// arrives after its own result never reopens it.
				const task = tasks.get(event.data.task_id) ?? { openedAt: Date.now() };
				task.captchaProvider = providerOf(event.data) ?? task.captchaProvider;
				task.taskKind = kindOf(event.data) ?? task.taskKind;
				task.challengeId = event.data.challenge_id ?? task.challengeId;
				tasks.set(event.data.task_id, task);
				joinable.add(taskKey(event.data.task_id));
				if (event.data.challenge_id) joinable.add(challengeKey(event.data.challenge_id));
			} else if (event.type === "captcha_solve_result" && event.data?.task_id && !isSettled(taskKey(event.data.task_id))) {
				const task = tasks.get(event.data.task_id) ?? { openedAt: Date.now() };
				task.status = event.data.status;
				task.durationMs = event.data.duration_ms;
				task.captchaProvider = providerOf(event.data) ?? task.captchaProvider;
				task.taskKind = kindOf(event.data) ?? task.taskKind;
				task.challengeId = event.data.challenge_id ?? task.challengeId;
				tasks.set(event.data.task_id, task);
				joinable.add(taskKey(event.data.task_id));
				if (event.data.challenge_id) joinable.add(challengeKey(event.data.challenge_id));
			} else if (event.type === "captcha_challenge_result" && event.data) {
				const key = event.data.challenge_id
					? challengeKey(event.data.challenge_id)
					: event.data.inferred && event.data.task_id
						? taskKey(event.data.task_id)
						: undefined;
				if (!key || isSettled(key)) continue;
				challenges.set(key, {
					status: event.data.status,
					durationMs: event.data.duration_ms,
					captchaProvider: providerOf(event.data),
					inferred: event.data.inferred,
				});
			}
		}
	})();
```

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

```ts captcha-gate.ts theme={null}
	const isOpen = (t: Outcome & { openedAt: number }) => !t.status && Date.now() - t.openedAt < TASK_SETTLE_MS;
	const openTasks = () => [...tasks.values()].filter(isOpen);
	const joinedResult = () => [...challenges].find(([id]) => joinable.has(id));
	const expectsChallengeResult = () => [...tasks.values()].some((task) =>
		task.taskKind !== "image_challenge" &&
		(task.challengeId || (task.status === "success" && task.captchaProvider !== "turnstile"))
	);

	async function until(done: () => boolean, timeoutMs: number) {
		const deadline = Date.now() + timeoutMs;
		while (!done() && Date.now() < deadline) await sleep(POLL_MS);
	}
```

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

```ts captcha-gate.ts theme={null}
	async function resolve(): Promise<Verdict> {
		await until(() => openTasks().length === 0 || Boolean(joinedResult()), TASK_SETTLE_MS);
		if (expectsChallengeResult() && challenges.size === 0) await until(() => challenges.size > 0, CHALLENGE_GRACE_MS);

		const page = await probePage();
		const challenge = joinedResult() ?? [...challenges][0];
		if (challenge) return describe("challenge", challenge[1].status, joinable.has(challenge[0]), challenge[1], page);

		const finished = [...tasks.values()].filter((t) => t.status);
		const task = finished.find((t) => t.status !== "success") ?? finished[0];
		if (task) return describe("task", task.status!, false, task, page);

		return describe("page", "unknown", false, {}, page);
	}

	return {
		/** A task the solver accepted has no terminal result yet. */
		holding: () => openTasks().length > 0,
		/** A terminal outcome is recorded that the agent hasn't been told about. */
		pending: () => challenges.size > 0 || [...tasks.values()].some((t) => !isOpen(t)),
		resolve,
		reset: () => {
			// Keep an open task unless a result joined to that task or its
			// challenge covers it.
			const result = joinedResult();
			const now = Date.now();
			for (const [id, t] of tasks) {
				const covered = result && (result[0] === taskKey(id) ||
					(t.challengeId && result[0] === challengeKey(t.challengeId)));
				if (isOpen(t) && !covered) continue;
				settledAt.set(taskKey(id), now);
				tasks.delete(id);
			}
			for (const id of challenges.keys()) settledAt.set(id, now);
			challenges.clear();
			if (openTasks().length === 0) joinable.clear();
		},
		/** Stop the telemetry stream. Call before deleting the browser. */
		close: () => streamController?.abort(),
	};
}

function describe(source: Verdict["source"], status: string, joined: boolean, outcome: Outcome, page: PageState): Verdict {
	const took = outcome.durationMs ? ` after ${(outcome.durationMs / 1000).toFixed(1)}s` : "";
	const kind = outcome.captchaProvider ? `${outcome.captchaProvider} ` : "";
	let headline: string;
	if (source === "page") {
		headline = "No terminal captcha telemetry arrived.";
	} else if (source === "task") {
		if (status !== "success") headline = `A ${kind}solver task ended as "${status}"${took}.`;
		else if (outcome.taskKind === "image_challenge") headline = `The solver completed one ${kind}image round${took}. The challenge may serve another round.`;
		else if (outcome.taskKind === "token") headline = `The solver returned a token for a ${kind}challenge${took}. No challenge-level outcome was reported, so this is not a cleared challenge.`;
		else headline = `The solver returned an answer for a ${kind}task${took}. No challenge-level outcome was reported, so this is not a cleared challenge.`;
	} else if (outcome.inferred) {
		headline = `Kernel inferred a ${kind}challenge result from a successful token task${took}. The page did not confirm it cleared.`;
	} else if (status === "solved") {
		headline = `Kernel observed the ${kind}challenge clear${took}. That is not proof the site accepted the solution.`;
	} else {
		headline = `Kernel reported the ${kind}challenge as "${status}"${took}.`;
	}
	const caveat =
		source === "challenge" && !joined
			? outcome.inferred
				? " It could not be joined to this page's solver tasks, so treat it as an uncorrelated token result."
				: " It could not be joined to this page's solver tasks, so treat it as an observation about the page."
			: "";
	const where = page.widgets.length
		? `A captcha widget is still on the page (${page.widgets.join(", ")}).`
		: "No captcha widget is visible on the page.";
	return { source, status, joined, captchaProvider: outcome.captchaProvider, taskKind: outcome.taskKind, inferred: outcome.inferred, durationMs: outcome.durationMs, page, message: `${headline}${caveat} ${where}` };
}
```

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

```ts captcha-gate.ts theme={null}
const PROBE = `
return await page.evaluate(() => {
  const groups = {
    recaptcha: 'iframe[src*="recaptcha"], .g-recaptcha',
    hcaptcha: 'iframe[src*="hcaptcha"], .h-captcha',
    turnstile: 'iframe[src*="challenges.cloudflare.com"], .cf-turnstile',
  };
  const onScreen = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
  return {
    widgets: Object.entries(groups)
      .filter(([, sel]) => [...document.querySelectorAll(sel)].some(onScreen))
      .map(([name]) => name),
    tokenPresent: [...document.querySelectorAll('[name="cf-turnstile-response"], [name="g-recaptcha-response"], [name="h-captcha-response"]')]
      .some((el) => el.value.length > 0),
  };
});
`;

const MODEL = (process.env.LOOP_MODEL as LoopModelRef | undefined) ?? "anthropic:claude-sonnet-5";

async function main(): Promise<void> {
	const task = process.argv[2];
	if (!task) throw new Error("pass the task as the first argument");
	requireLoopEnvApiKeyForModel(MODEL);

	const kernel = new KERNEL();
	const browser = await kernel.browsers.create({
		stealth: true,
		telemetry: { browser: { captcha: { enabled: true } } },
	});
	const kb = attach({ client: kernel, browser });

	const probePage = async (): Promise<PageState> => {
		const { result } = await kernel.browsers.playwright.execute(browser.session_id, { code: PROBE });
		return (result as PageState | undefined) ?? { widgets: [], tokenPresent: false };
	};
	const gate = createCaptchaGate(kernel, browser.session_id, probePage);

	try {
		const compiled = kb.compile({ model: MODEL, tools: loop.toolsets.browser() });
		const harness = new AgentHarness({
			session: await new InMemorySessionRepo().create({ id: browser.session_id }),
			model: compiled.model,
			models: compiled.models,
			tools: [...compiled.tools],
			activeToolNames: compiled.tools.map((tool) => tool.name),
			systemPrompt:
				"Use the supplied browser tools to complete the task efficiently. If you see a captcha or " +
				"similar challenge and nothing has told you otherwise, wait for it to be solved automatically " +
				"before acting.",
		});
		compiled.activate(harness);

		harness.on("tool_call", async () => {
			if (!gate.holding() && !gate.pending()) return undefined;
			const verdict = await gate.resolve();
			gate.reset();
			console.log(`[captcha] ${verdict.source}:${verdict.status} — ${verdict.message}`);
			// Telemetry decides what to say; the page decides whether to interrupt.
			if (verdict.page.widgets.length === 0 || verdict.page.tokenPresent) return undefined;
			return { block: true, reason: verdict.message };
		});

		const final = await harness.prompt(task);
		for (const block of final.content) if (block.type === "text") console.log(block.text);
	} finally {
		gate.close();
		await kb.dispose();
		await kernel.browsers.deleteByID(browser.session_id);
	}
}

void main();
```

> [accordion] The complete captcha-gate.ts
>
> ```ts captcha-gate.ts theme={null}
> /**
>  * Pause a browser-loop agent while Kernel's captcha solver works, and tell it
>  * what actually happened when the solve ends.
>  *
>  * Usage:
>  *   KERNEL_API_KEY=... ANTHROPIC_API_KEY=... npx tsx captcha-gate.ts "your task"
>  */
> import KERNEL from "@onkernel/sdk";
> import { AgentHarness, InMemorySessionRepo } from "@earendil-works/pi-agent-core";
> import { loop } from "@onkernel/browser-loop";
> import { attach, requireLoopEnvApiKeyForModel, type LoopModelRef } from "@onkernel/browser-loop/pi";
>
> const TASK_SETTLE_MS = 20_000;
> const CHALLENGE_GRACE_MS = 8_000;
> const POLL_MS = 250;
>
> const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
>
> interface PageState {
> 	widgets: string[];
> 	tokenPresent: boolean;
> }
>
> interface Outcome {
> 	status?: string;
> 	durationMs?: number;
> 	captchaProvider?: string;
> 	taskKind?: string;
> 	challengeId?: string;
> 	inferred?: boolean;
> }
>
> interface Verdict extends Outcome {
> 	source: "challenge" | "task" | "page";
> 	status: string;
> 	joined: boolean;
> 	page: PageState;
> 	message: string;
> }
>
> function createCaptchaGate(kernel: KERNEL, sessionId: string, probePage: () => Promise<PageState>) {
> 	const tasks = new Map<string, Outcome & { openedAt: number }>();
> 	const challenges = new Map<string, Outcome & { status: string }>();
> 	const joinable = new Set<string>();
> 	// Task and challenge keys reported and cleared by reset(), each stamped
> 	// with when that happened. A late-arriving duplicate within TASK_SETTLE_MS
> 	// of that -- delivery is unordered, a start can arrive after its own
> 	// result -- is dropped instead of reopening an already-told episode. Past
> 	// that window it's treated as fresh, since Kernel can reuse the same
> 	// challenge_id when an episode continues across a page reload.
> 	const settledAt = new Map<string, number>();
> 	const taskKey = (id: string) => `task:${id}`;
> 	const challengeKey = (id: string) => `challenge:${id}`;
> 	const providerOf = (data: { captcha_provider?: string; captcha_type?: string }) =>
> 		data.captcha_provider ?? (data.captcha_type === "press_and_hold" ? "human" : data.captcha_type);
> 	const kindOf = (data: { task_kind?: string; captcha_type?: string }) => {
> 		if (data.task_kind) return data.task_kind;
> 		if (data.captcha_type === "press_and_hold") return "press_and_hold";
> 		if (["turnstile", "geetest", "recaptcha_v3"].includes(data.captcha_type ?? "")) return "token";
> 	};
> 	const isSettled = (id: string) => {
> 		const at = settledAt.get(id);
> 		return at !== undefined && Date.now() - at < TASK_SETTLE_MS;
> 	};
> 	let streamController: AbortController | undefined;
>
> 	void (async () => {
> 		const stream = await kernel.browsers.telemetry.stream(sessionId);
> 		streamController = stream.controller;
> 		for await (const { event } of stream) {
> 			if (event.category !== "captcha") continue;
>
> 			if (event.type === "captcha_solve_started" && event.data?.task_id && !isSettled(taskKey(event.data.task_id))) {
> 				// A result for an unseen task lands already closed, so a start that
> 				// arrives after its own result never reopens it.
> 				const task = tasks.get(event.data.task_id) ?? { openedAt: Date.now() };
> 				task.captchaProvider = providerOf(event.data) ?? task.captchaProvider;
> 				task.taskKind = kindOf(event.data) ?? task.taskKind;
> 				task.challengeId = event.data.challenge_id ?? task.challengeId;
> 				tasks.set(event.data.task_id, task);
> 				joinable.add(taskKey(event.data.task_id));
> 				if (event.data.challenge_id) joinable.add(challengeKey(event.data.challenge_id));
> 			} else if (event.type === "captcha_solve_result" && event.data?.task_id && !isSettled(taskKey(event.data.task_id))) {
> 				const task = tasks.get(event.data.task_id) ?? { openedAt: Date.now() };
> 				task.status = event.data.status;
> 				task.durationMs = event.data.duration_ms;
> 				task.captchaProvider = providerOf(event.data) ?? task.captchaProvider;
> 				task.taskKind = kindOf(event.data) ?? task.taskKind;
> 				task.challengeId = event.data.challenge_id ?? task.challengeId;
> 				tasks.set(event.data.task_id, task);
> 				joinable.add(taskKey(event.data.task_id));
> 				if (event.data.challenge_id) joinable.add(challengeKey(event.data.challenge_id));
> 			} else if (event.type === "captcha_challenge_result" && event.data) {
> 				const key = event.data.challenge_id
> 					? challengeKey(event.data.challenge_id)
> 					: event.data.inferred && event.data.task_id
> 						? taskKey(event.data.task_id)
> 						: undefined;
> 				if (!key || isSettled(key)) continue;
> 				challenges.set(key, {
> 					status: event.data.status,
> 					durationMs: event.data.duration_ms,
> 					captchaProvider: providerOf(event.data),
> 					inferred: event.data.inferred,
> 				});
> 			}
> 		}
> 	})();
>
> 	const isOpen = (t: Outcome & { openedAt: number }) => !t.status && Date.now() - t.openedAt < TASK_SETTLE_MS;
> 	const openTasks = () => [...tasks.values()].filter(isOpen);
> 	const joinedResult = () => [...challenges].find(([id]) => joinable.has(id));
> 	const expectsChallengeResult = () => [...tasks.values()].some((task) =>
> 		task.taskKind !== "image_challenge" &&
> 		(task.challengeId || (task.status === "success" && task.captchaProvider !== "turnstile"))
> 	);
>
> 	async function until(done: () => boolean, timeoutMs: number) {
> 		const deadline = Date.now() + timeoutMs;
> 		while (!done() && Date.now() < deadline) await sleep(POLL_MS);
> 	}
>
> 	async function resolve(): Promise<Verdict> {
> 		await until(() => openTasks().length === 0 || Boolean(joinedResult()), TASK_SETTLE_MS);
> 		if (expectsChallengeResult() && challenges.size === 0) await until(() => challenges.size > 0, CHALLENGE_GRACE_MS);
>
> 		const page = await probePage();
> 		const challenge = joinedResult() ?? [...challenges][0];
> 		if (challenge) return describe("challenge", challenge[1].status, joinable.has(challenge[0]), challenge[1], page);
>
> 		const finished = [...tasks.values()].filter((t) => t.status);
> 		const task = finished.find((t) => t.status !== "success") ?? finished[0];
> 		if (task) return describe("task", task.status!, false, task, page);
>
> 		return describe("page", "unknown", false, {}, page);
> 	}
>
> 	return {
> 		/** A task the solver accepted has no terminal result yet. */
> 		holding: () => openTasks().length > 0,
> 		/** A terminal outcome is recorded that the agent hasn't been told about. */
> 		pending: () => challenges.size > 0 || [...tasks.values()].some((t) => !isOpen(t)),
> 		resolve,
> 		reset: () => {
> 			// Keep an open task unless a result joined to that task or its
> 			// challenge covers it.
> 			const result = joinedResult();
> 			const now = Date.now();
> 			for (const [id, t] of tasks) {
> 				const covered = result && (result[0] === taskKey(id) ||
> 					(t.challengeId && result[0] === challengeKey(t.challengeId)));
> 				if (isOpen(t) && !covered) continue;
> 				settledAt.set(taskKey(id), now);
> 				tasks.delete(id);
> 			}
> 			for (const id of challenges.keys()) settledAt.set(id, now);
> 			challenges.clear();
> 			if (openTasks().length === 0) joinable.clear();
> 		},
> 		/** Stop the telemetry stream. Call before deleting the browser. */
> 		close: () => streamController?.abort(),
> 	};
> }
>
> function describe(source: Verdict["source"], status: string, joined: boolean, outcome: Outcome, page: PageState): Verdict {
> 	const took = outcome.durationMs ? ` after ${(outcome.durationMs / 1000).toFixed(1)}s` : "";
> 	const kind = outcome.captchaProvider ? `${outcome.captchaProvider} ` : "";
> 	let headline: string;
> 	if (source === "page") {
> 		headline = "No terminal captcha telemetry arrived.";
> 	} else if (source === "task") {
> 		if (status !== "success") headline = `A ${kind}solver task ended as "${status}"${took}.`;
> 		else if (outcome.taskKind === "image_challenge") headline = `The solver completed one ${kind}image round${took}. The challenge may serve another round.`;
> 		else if (outcome.taskKind === "token") headline = `The solver returned a token for a ${kind}challenge${took}. No challenge-level outcome was reported, so this is not a cleared challenge.`;
> 		else headline = `The solver returned an answer for a ${kind}task${took}. No challenge-level outcome was reported, so this is not a cleared challenge.`;
> 	} else if (outcome.inferred) {
> 		headline = `Kernel inferred a ${kind}challenge result from a successful token task${took}. The page did not confirm it cleared.`;
> 	} else if (status === "solved") {
> 		headline = `Kernel observed the ${kind}challenge clear${took}. That is not proof the site accepted the solution.`;
> 	} else {
> 		headline = `Kernel reported the ${kind}challenge as "${status}"${took}.`;
> 	}
> 	const caveat =
> 		source === "challenge" && !joined
> 			? outcome.inferred
> 				? " It could not be joined to this page's solver tasks, so treat it as an uncorrelated token result."
> 				: " It could not be joined to this page's solver tasks, so treat it as an observation about the page."
> 			: "";
> 	const where = page.widgets.length
> 		? `A captcha widget is still on the page (${page.widgets.join(", ")}).`
> 		: "No captcha widget is visible on the page.";
> 	return { source, status, joined, captchaProvider: outcome.captchaProvider, taskKind: outcome.taskKind, inferred: outcome.inferred, durationMs: outcome.durationMs, page, message: `${headline}${caveat} ${where}` };
> }
>
> const PROBE = `
> return await page.evaluate(() => {
>   const groups = {
>     recaptcha: 'iframe[src*="recaptcha"], .g-recaptcha',
>     hcaptcha: 'iframe[src*="hcaptcha"], .h-captcha',
>     turnstile: 'iframe[src*="challenges.cloudflare.com"], .cf-turnstile',
>   };
>   const onScreen = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
>   return {
>     widgets: Object.entries(groups)
>       .filter(([, sel]) => [...document.querySelectorAll(sel)].some(onScreen))
>       .map(([name]) => name),
>     tokenPresent: [...document.querySelectorAll('[name="cf-turnstile-response"], [name="g-recaptcha-response"], [name="h-captcha-response"]')]
>       .some((el) => el.value.length > 0),
>   };
> });
> `;
>
> const MODEL = (process.env.LOOP_MODEL as LoopModelRef | undefined) ?? "anthropic:claude-sonnet-5";
>
> async function main(): Promise<void> {
> 	const task = process.argv[2];
> 	if (!task) throw new Error("pass the task as the first argument");
> 	requireLoopEnvApiKeyForModel(MODEL);
>
> 	const kernel = new KERNEL();
> 	const browser = await kernel.browsers.create({
> 		stealth: true,
> 		telemetry: { browser: { captcha: { enabled: true } } },
> 	});
> 	const kb = attach({ client: kernel, browser });
>
> 	const probePage = async (): Promise<PageState> => {
> 		const { result } = await kernel.browsers.playwright.execute(browser.session_id, { code: PROBE });
> 		return (result as PageState | undefined) ?? { widgets: [], tokenPresent: false };
> 	};
> 	const gate = createCaptchaGate(kernel, browser.session_id, probePage);
>
> 	try {
> 		const compiled = kb.compile({ model: MODEL, tools: loop.toolsets.browser() });
> 		const harness = new AgentHarness({
> 			session: await new InMemorySessionRepo().create({ id: browser.session_id }),
> 			model: compiled.model,
> 			models: compiled.models,
> 			tools: [...compiled.tools],
> 			activeToolNames: compiled.tools.map((tool) => tool.name),
> 			systemPrompt:
> 				"Use the supplied browser tools to complete the task efficiently. If you see a captcha or " +
> 				"similar challenge and nothing has told you otherwise, wait for it to be solved automatically " +
> 				"before acting.",
> 		});
> 		compiled.activate(harness);
>
> 		harness.on("tool_call", async () => {
> 			if (!gate.holding() && !gate.pending()) return undefined;
> 			const verdict = await gate.resolve();
> 			gate.reset();
> 			console.log(`[captcha] ${verdict.source}:${verdict.status} — ${verdict.message}`);
> 			// Telemetry decides what to say; the page decides whether to interrupt.
> 			if (verdict.page.widgets.length === 0 || verdict.page.tokenPresent) return undefined;
> 			return { block: true, reason: verdict.message };
> 		});
>
> 		const final = await harness.prompt(task);
> 		for (const block of final.content) if (block.type === "text") console.log(block.text);
> 	} finally {
> 		gate.close();
> 		await kb.dispose();
> 		await kernel.browsers.deleteByID(browser.session_id);
> 	}
> }
>
> void main();
> ```

## What the agent is told

Every verdict pairs a telemetry claim with the page state it was checked against:

| Source | Message |
| - | - |
| Observed challenge result | "Kernel observed the recaptcha\_v2 challenge clear after 18.0s. That is not proof the site accepted the solution. No captcha widget is visible on the page." |
| Inferred challenge result | "Kernel inferred a geetest challenge result from a successful token task after 3.9s. The page did not confirm it cleared. No captcha widget is visible on the page." |
| Unjoinable observed result | Same, plus "It could not be joined to this page's solver tasks, so treat it as an observation about the page." |
| Unjoinable inferred result | Same, plus "It could not be joined to this page's solver tasks, so treat it as an uncorrelated token result." |
| Token task result only | "The solver returned a token for a turnstile challenge after 3.9s. No challenge-level outcome was reported, so this is not a cleared challenge. A captcha widget is still on the page (turnstile)." |
| Image-round task result only | "The solver completed one recaptcha\_v2 image round after 3.9s. The challenge may serve another round. A captcha widget is still on the page (recaptcha)." |
| Nothing terminal arrived | "No terminal captcha telemetry arrived. A captcha widget is still on the page (turnstile)." |

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

* [Telemetry Categories](https://www.kernel.sh/docs/browsers/telemetry/categories.md#captcha-event-fields) — captcha event fields and correlation rules
* [Stream Telemetry](https://www.kernel.sh/docs/browsers/telemetry/streaming.md) — resuming a dropped stream, filtering by category
* [Stealth mode](https://www.kernel.sh/docs/browsers/bot-detection/stealth.md) — what the automatic captcha solver covers
* [Playwright with Computer Use Fallback](https://www.kernel.sh/docs/browsers/playwright-computer-use-fallback) — holding and redirecting an agent mid-run for a different reason


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.