Watch the AI test your app. Take over the moment it matters.
Handoff runs end-to-end tests from a plain-language goal on your own model key, streams the browser to your screen while it works, and lets you take the controls mid-run. What you do by hand becomes a named test step. Replays never call a model.
Free plan included. Bring any OpenAI-compatible, Anthropic, Google or local model.

Describe the goal
“Sign in as the standard user, add a todo, sign out.” Point it at a staging environment with an allowlist of origins and the secrets it may type.
Watch it work
The browser streams to the run page. Each step shows the action, the accessibility tree the model saw, and the outcome. Nothing happens off screen.
Take control when needed
Press Take control. The agent is locked out, your clicks and keystrokes land in the page, and every action is recorded with a durable locator. Hand back when done.
Replay without a model
Accept the proposed steps and the test replays deterministically, in CI or on a schedule, with zero model calls. Export it as plain Playwright any time.
Autonomy is only useful if you can interrupt it.
Most AI testing tools hand you a report after the fact. Handoff is built around a control lease: at every moment exactly one party drives the browser, and the human can take that lease at any step.
Live view with real takeover
Frames stream over the browser's own screencast protocol, so your clicks and the agent's clicks are the same event in the same coordinate space. A dropped connection never loses your control: the lease lives on the server, not in the socket.
Manual actions become test steps
Every click during a takeover is resolved to a locator before the page can change. The before-and-after accessibility diff proposes assertions. You review a named, parameterised step and confirm it before anything is saved.
Replay is deterministic, by design
Recorded steps replay with cached locators, web-first assertions and the app's own readiness signal. No fixed sleeps, no idle-network waits, no model in the loop. There is a test in our own suite that fails if a model call ever appears there.
One broker talks to the browser. Nobody else.
The agent and the viewer are both clients of a session broker that holds the only connection to Chrome. Control is a lease with an epoch that increments on every transfer. A late agent command carrying an old epoch is rejected, so the race between “the agent clicks” and “you clicked first” cannot happen.
- Transfer waits for in-flight commands and navigation to settle, then snapshots the page.
- Hardware-level input ignore is the backstop while control changes hands.
- After a takeover the agent re-plans from a fresh observation. It never replays stale steps.
- Each session gets its own browser context, and on plain servers its own hardened, sandboxed Chrome process.
// the control lease, as stored on the server
{
sessionId: "01a094bb-5326-7e13-…",
owner: "human", // 'agent' | 'human' | 'transferring' | 'none'
epoch: 3, // increments on every transfer
holderId: "user_…",
expiresAt: 1789123456789 // heartbeat-renewed by the viewer
}
// an agent command with epoch 2 now returns:
{ code: "STALE_EPOCH", message: "Control changed hands. Re-observe before acting." }Every test exports as plain Playwright. On every plan.
The export is generated code you can read, edit and run under stock @playwright/test: role-based locators, the readiness signal, console and network collectors, and an optional accessibility scan. Secrets become environment variables. Our own test suite runs the exported file to prove it.
// Exported from TC-0001 — Sign in and manage todos
// Plain @playwright/test. No model calls,
// no fixed sleeps, no idle-network waits.
import { expect, test } from '@playwright/test';
test("TC-0001 Sign in and manage todos", async ({ page }) => {
await page.goto(BASE_URL);
await ready(); // the app's own readiness signal
await test.step("1. Type into Username", async () => {
await page
.getByRole('textbox', { name: "Username", exact: true })
.fill("standard_user");
});
await test.step("2. Enter the password secret", async () => {
const pw = page.getByRole('textbox', { name: "Password" });
await pw.fill(secret("password"));
await pw.press('Enter');
await ready();
});
await test.step("3. Assert URL contains /dashboard", async () => {
await expect(page).toHaveURL(new RegExp("/dashboard"));
});
expect(consoleErrors, 'console/page errors').toEqual([]);
expect(badResponses, 'unexpected 4xx/5xx').toEqual([]);
});Requirements to report, without a second tool.
Link requirements to test cases, group cases into plans, run a plan as a cycle. The traceability matrix and the cycle report are derived from what actually ran: which requirement is verified, failing, untested or uncovered, which execution failed at which step, and what it cost in model calls.
- A failed execution opens exactly one incident, with the failing step's message.
- Incidents export to Jira Cloud with one click once a connection is configured.
- Reports download as Markdown so they travel with the release.

Everything a run touches, accounted for.
Bring your own model
Anthropic and Google natively; anything OpenAI-compatible by base URL and key; local models through a small edge runner that never opens an inbound port. Capabilities are probed when you bind, and shown, not assumed.
Cost and token accounting
Every model call is recorded with input, output and cache-read tokens and a cost in cents. Usage per month is on the settings page, next to the plan limits.
Self-healing with guardrails
Locators heal, assertions never do. A candidate must keep the same role and a similar accessible name, it is proposed as a diff for approval, and three heals in a month escalates as a design defect instead of a quiet fix.
Visual regression, advisory
Each replayed step keeps a baseline picture. Later runs compare with a perceptual hash and a pixel diff. Changes are shown in a separate lane and never fail the step on their own.
Approval for dangerous actions
Payments, deletions, sending mail and navigation off the allowlist pause the run and wait for a person to approve or deny. The verdict is deterministic; no model decides what is dangerous.
Free riders on every step
Console errors, uncaught exceptions and unexpected 4xx/5xx responses are collected on every observation. A replayed step fails on a page error or a 5xx without any extra configuration.
Numbers we design against
Published analyses of flaky tests and our own measurements. They decide where the engineering effort goes.
Assume every page is hostile.
A browser under AI control visits pages that may try to instruct it. Handoff treats page content as data, keeps secrets out of prompts entirely, constrains the agent to a typed action schema, and isolates each session's browser.
- Secrets are typed from placeholders at keystroke time inside the browser driver, only on the origins you allow. Prompts, logs, traces and exports only ever see
{{secret:name}}. - Passwords and card-shaped fields are masked at capture, never after the fact.
- Navigation is limited to the environment's allowlist; other URLs need approval.
- User-supplied URLs are resolved and checked against private ranges on every request.
- Your model key is stored in a file-backed secret store, never in the database.
Questions people ask first
Does it need our model key, or do you host one?
Does it solve CAPTCHAs or log in through MFA?
What happens when the app changes and a locator breaks?
Can we leave?
Where does it run?
Run your first supervised test today.
Create an organisation, point Handoff at a staging app, bind your model key, and watch the first run in your browser.