Most automation treats a second factor as a failure. It does not have to be. A sign-in run that meets a wall it cannot clear PARKS by default: the browser stays open, the credit reservation is refunded, and there are four ways forward. This guide covers all four and, more importantly, how to decide which one a given situation calls for.
Decide first: whose password is it?
This is the fork that determines everything else. If the credential is yours to hold — a service account, your own portal login — store it in the vault and reference it by name. If it belongs to your customer, do not hold it at all: mint a connect link and let them sign in themselves, so only the resulting session is ever captured. Choosing the vault for someone else's password is the decision that gets regretted later.
Path 1 — a vaulted secret
POST /api/v1/secrets stores an encrypted, per-tenant value that no read ever returns. Reference it in the prompt as {{secret:NAME}}; the value is resolved inside the browser at fill time, after the planner has decided what to type, and is redacted from the returned path, the frames and the logs. A run that needs a secret you have not stored returns code "credential_missing" with the name it wanted.
curl -X POST https://twin-browser.com/api/v1/secrets \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{"name":"PORTAL_PASSWORD","value":"…"}'
# GET /api/v1/secrets returns NAMES only. There is no read that returns a value.Path 2 — no human at all, for an emailed code
Connect an IMAP inbox and the engine fetches and fills the emailed verification code itself; the pause never surfaces. The inbox is validated against a real IMAP connection before it is stored, so a wrong host or app-password is rejected at setup rather than discovered mid-run. The app-password is write-only and never returned.
curl -X POST https://twin-browser.com/api/v1/email-inbox \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{
"host": "imap.gmail.com",
"port": 993,
"user": "ops@acme.com",
"pass": "…app password…",
"secure": true
}'
# → { "name": "email:ops@acme.com", "host": "imap.gmail.com", "user": "ops@acme.com" }
# GET /api/v1/email-inbox → { inboxes: [ { name, host, user } ] }
# DELETE /api/v1/email-inbox?name=… → disconnectPath 3 — hand back a code the run cannot fetch
An authenticator (TOTP) code, a phone approval, or a code that arrives somewhere Twin cannot read parks the run. Branch on status === "paused", keep the sessionId, and POST it back with the code. The same call can also supply a credential the run turned out to need — merged into the parked session's vault so the same browser continues, with the values redacted from every frame, step and log.
# The run came back as:
# { "status":"paused", "sessionId":"sess_…", "reason":"…",
# "challenge":"sms-code", "codeRequired":true }
curl -X POST https://twin-browser.com/api/v1/runs/run_…/resume \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{ "sessionId": "sess_…", "code": "483920" }'
# …or supply the credential it discovered it needed:
# { "sessionId": "sess_…",
# "secrets": { "email": "ops@acme.com", "password": "…" } }Path 3b — take the wheel yourself
Some steps are neither a code nor automatable: an unusual consent dialog, a one-off confirmation. GET /api/v1/runs/{id}/stream mirrors the parked browser as a live screencast (free — it drives no agent), and POST /api/v1/runs/{id}/input sends a click, fill, key, scroll or goto. Coordinates are FRACTIONS of the viewport, 0..1 from the top-left, hit-tested server-side, so they work from a screenshot at any size. There is no "done" call: the run re-checks the page after each interaction and continues by itself once the block clears.
curl -X POST https://twin-browser.com/api/v1/runs/run_…/input \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{ "event": { "kind": "click", "x": 0.62, "y": 0.41 } }'
# → { "ok": true, "url": "https://…" }Path 4 — the connect handoff
When the password is not yours, or the site hard-challenges automated sign-in from datacenter IPs and simply cannot be signed into by an agent, mint a link. It is single-use, scoped to one host, and expires in about thirty minutes. The person opens it, signs in by hand — password, second factor, CAPTCHA — and the resulting SESSION is captured. Re-run the task and it restores.
curl -X POST https://twin-browser.com/api/v1/connect/sessions \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{ "host": "portal.example.com",
"account": "acme-ops",
"redirectUri": "https://your.app/connected" }'
# → 201 { "url": "https://twin-browser.com/connect/…", "expiresAt": "…", "ttlMinutes": 30 }
curl "https://twin-browser.com/api/v1/connect/sessions" \
-H "Authorization: Bearer $TWIN_API_KEY"
# → { "sessions": [ { "id":"…", "status":"connected", "connected_at":"…" } ] }Several logins on one site
Give each an `account` label — at connect time, and on every later run. GET /api/v1/accounts lists what you have as { label, host, emailPreview }, masked and never a credential, so an agent can discover which logins exist before choosing one. Each labelled account also gets its own sticky egress identity and its own cadence budget.
The one popup OAuth cannot do
A "Continue with Google" sign-in only completes in a real local browser, so the hosted connect flow cannot drive it. For those, capture the cookies on the user's own machine and POST them to /api/v1/sessions/import with the host; they are assembled into the same encrypted session a hosted sign-in would have produced, stored under both apex and www forms of the host.