A language model can reason about the web but cannot click, type, sign in or hold a session. To act, it needs an execution layer: something that takes a goal and a URL, drives a real browser, and returns a result you can check. This guide is the shortest honest path from an API key to a run that did something — including the two request shapes people most often get wrong.
What does giving an agent a browser actually mean?
It means exposing one high-level action — "sign in and export this month's invoices" — and having a system carry it out end to end in a real, JavaScript-capable browser. The page is serialized into a compact indexed map of its interactive elements, a planner picks the next action against that map, the action executes, and the loop repeats until an explicit success condition holds or the step budget runs out. You never ship Chromium, manage a session, or hand-write a selector.
What are the two request shapes?
This is the single most common 400. There is an AD-HOC shape for free text — { url, prompt, success } — and a NAMED shape for the engine's built-in goal registry — { target, goal }, where `goal` is a short identifier like login, search, extract, reply or like. Putting a sentence in the named shape is rejected at the edge with a 400 that points you at the ad-hoc form, before a credit is reserved.
- Free text → { url, prompt, success }. `success` is required, not optional.
- Registry goal → { target, goal }. `goal` is one word, not a sentence.
- A goal containing whitespace in the named shape is a 400, by design.
Why is a success condition required?
Because letting the planner decide whether it succeeded is how automation reports a green run that did nothing. The success spec is an external, checkable test: { kind: "urlIncludes", value } , { kind: "textVisible", value }, { kind: "statusText", match }, { kind: "extracted" }, or an allOf / anyOf combination of those. The run ends when it holds.
curl -X POST https://twin-browser.com/api/v1/run \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://app.example.com/invoices",
"prompt": "Open the May 2026 statement and download it as PDF",
"success": { "kind": "textVisible", "value": "Download complete" }
}'
# {
# "success": true,
# "steps": 6,
# "path": [ /* the actions taken, secret values redacted */ ],
# "indexedState": { /* the final indexed element map */ },
# "runId": "run_…",
# "credits_charged": 10
# }How do I keep an authenticated session?
Store the password once and reference it by name — never paste it into a prompt. A secret stored at POST /api/v1/secrets is referenced as {{secret:NAME}} and resolved inside the browser at fill time, after the planner has already decided what to type there. If the password is not yours to hold, mint a connect link instead and let its owner sign in themselves; only the session is captured.
# Store it once — free, encrypted, never returned by any read
curl -X POST https://twin-browser.com/api/v1/secrets \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{"name":"EXAMPLE_PASSWORD","value":"…"}'
# Reference it by token in the goal
# "prompt": "sign in as ops@acme.com with {{secret:EXAMPLE_PASSWORD}} and …"
# A run that needs a secret you have not stored comes back with
# { "code": "credential_missing", "missingSecret": "EXAMPLE_PASSWORD" }What happens when it hits a 2FA prompt?
It parks rather than failing. A sign-in run that meets a wall it cannot auto-resolve returns { status: "paused", sessionId, reason, challenge } with the browser still open and the credit reservation refunded. Hand back the code with POST /api/v1/runs/{id}/resume, drive the page yourself with /input, or send the person a connect link. Pass hitl: false if you would rather have a plain failure.
What changes on the second run?
The first run of a new intent is a cold start: observe, plan, act, and compile the successful trace into a skill. Send the next — even re-phrased — request to POST /api/v1/dispatch instead of /run, and the semantic cache matches it to that skill and replays it deterministically for 2 credits instead of 10. That is the whole cost story, and it is why dispatch is the better default for anything you will ask twice.
How do I wire this into what I already use?
Three doors, one execution layer. The REST API under /api/v1/* is plain HTTPS with a Bearer key — there is no SDK package to install. The MCP server registers the same surface as tools for Cursor, Claude Desktop, Claude Code and Cline over stdio. And for LangChain or AutoGen, a five-line function that POSTs to /dispatch is the whole adapter.