Agent sessions: secrets, live view and browser state
Part of the Agent sessions reference, which lists every call.
Session secrets
A value the agent has to type but that you would rather not put in a message — a password, a one-time code, a payment card number — can be registered for one session. You get back a handle, write the handle in your message, and Driftstack puts the value into the step only as it is sent to the browser. The model plans with the handle, and the plan, the transcript, the step results and every response carry the handle in place of the value. Once typed, the value is on the page; what happens when the page shows it again is described below.
A session secret works like a saved credential, with three differences:
- It belongs to one session. Only the session it was registered for can type it. Another session, even one of yours, does not hold it, and a step there that names it fails before anything is typed.
- It is never stored. It is held in the server’s memory only — never in a database or a log — and dropped when the session ends, when you delete it, when the service restarts, or when its time limit runs out (one hour unless you set another, at most 24 hours). After a restart, register it again. A secret a step has typed, or tried to, is the one exception to the time limit: from then on it is never typed or listed again, but its value is kept until the session ends or you delete it, only so it can still be hidden from the model.
- You register it through the API, with the key that drives the session. Card numbers are accepted, because nothing is kept beyond the session.
The same website lock applies. You register the websites it may be typed on,
and a step types it only on an https:// page whose host the browser reports
as one of them at that moment. A field inside an embedded frame — a card field
a payment provider hosts on the shop’s page — needs the frame’s website listed
too, so register the provider’s host beside the shop’s. Anywhere else the step
is refused before anything is typed:
the secret "Card" was not typed: this page is on evil.example.net, and it
may only be typed on shop.example.com
What the model is shown after the value has been typed is guarded as for a saved credential: see What the model is shown after a value is typed, including what that does not cover. The step results and the transcript are made from the same text, so they carry the placeholder too. A card number is recognised however the page groups its digits; a page that shows only part of it, such as the last four digits, is not recognised. Once you delete a secret its value is gone, so a later turn can no longer recognise it on the page, as for a deleted saved credential.
Limits
| Secrets per session | 20 |
| Label | 1–120 characters |
| Value | 1–4096 bytes of UTF-8 |
| Websites per secret | 1–10 host names, 253 characters each |
| Time limit | 60–86400 seconds; default 3600 |
Register a session secret
POST /v1/agent-sessions/{id}/secrets
Anyone who may send messages to the session may register one: a key with the
write scope (the session’s owner, or an admin of the owner’s team) or the
session’s own control key. The session must be active.
{
"label": "Card",
"secret": "4242424242424242",
"sites": ["shop.example.com", "pay.example.net"],
"ttl_seconds": 1800
}
| Field | Meaning |
|---|---|
label |
Required. Your own words for it, 1–120 characters. Named in place of the value when a step that would type it is refused, in a sentence the model also reads, so put nothing secret in it. |
secret |
Required. The value, 1–4096 bytes of UTF-8. |
sites |
Required. 1–10 host names it may be typed on, such as shop.example.com — no https://, path or port. |
include_subdomains |
Optional, default false. Also allow any subdomain of each site, as for a saved credential. |
ttl_seconds |
Optional. How long it may be typed: 60–86400 seconds, default 3600. The session ending drops it sooner. One a step has typed, or tried to, is kept past it, never typed again, until the session ends (see above). |
The answer (201) never contains the value:
{
"handle": "sec_4be1c09d2f7a4e6b8c35d1a0f9e27b64",
"label": "Card",
"sites": ["shop.example.com", "pay.example.net"],
"include_subdomains": false,
"created_at": "2026-10-02T12:00:00.000Z",
"expires_at": "2026-10-02T12:30:00.000Z"
}
Write the handle inside a placeholder in your message. The agent types it as the whole value of one field, the way a saved credential is typed:
Type {{credential:sec_4be1c09d2f7a4e6b8c35d1a0f9e27b64}} into the card number field
| Status | Meaning |
|---|---|
201 |
Registered. |
400 |
A field is missing, out of bounds or not accepted, or a website is not a host name. The detail names the field, or the website by its position, never the value; an unknown field is also listed by name in the x-driftstack-unknown-fields header. |
404 |
No such session, or not one you can reach. |
409 |
The session is not active (session_status, with closed_reason and closed_reason_detail when it was closed), or it already holds 20 secrets. Nothing was registered. |
The SDKs: agentSessions.registerSecret(id, body) in TypeScript,
agent_sessions.register_secret(id, label=..., secret=..., sites=...) in
Python (sync and async) and AgentSessions.RegisterSecret(ctx, id, body) in Go.
None of them retries a register by itself: a retry could register the value
twice.
If a register answer never arrived. A register can reach us even when its
answer does not reach you: a 520, a 524, a timeout or a dropped connection
leaves you unsure whether the secret was stored. Do not send the register
again straight away. A register does not take an Idempotency-Key today, so a
second one stores a second copy of the secret under a new handle. Instead:
- Give every secret you register a label that is unique within the session
(for example
card-<your order id>). - After a failed or missing answer, list what the session holds:
GET /v1/agent-sessions/{id}/secrets. - Look for your label. If it is there once, use that
handle; you are done. If it is there more than once, keep one and delete the others withDELETE /v1/agent-sessions/{id}/secrets/{handle}. - Only if your label is not listed, register the secret again.
The list never shows a value, only handles, labels, sites and times, so it is safe to call from the same code that registers.
List session secrets
GET /v1/agent-sessions/{id}/secrets
Requires read:sessions (broad read or account_owner also work), or the
session’s control key. Returns the secrets the session holds now — the same
fields as the register answer, never a value:
{ "data": [{ "handle": "sec_4be1c09d2f7a4e6b8c35d1a0f9e27b64", "label": "Card", "...": "..." }] }
A secret that was deleted or ran out of time is not listed, and a session that
has ended lists none. listSecrets(id), list_secrets(id) and
ListSecrets(ctx, id) return the data array.
Delete a session secret
DELETE /v1/agent-sessions/{id}/secrets/{handle}
Drops the secret at once and answers 204, including one past its time limit
that is kept only to hide it from the model; a handle the session does not
hold answers 404. It takes effect in a turn that is already running too:
each step that types a secret first checks that the session still holds it,
so a step after the deletion is refused with invalid_request and a reason
that says the session no longer holds it. deleteSecret(id, handle),
delete_secret(id, handle) and DeleteSecret(ctx, id, handle).
Fetch a captured screenshot
GET /v1/agent-sessions/{id}/captures/{captureId}
Downloads a screenshot the agent took during a turn — the captureId
(cap_<uuid>) on a success result of a capture step. The response body
is the image itself, image/png or image/jpeg (see the Content-Type
header), not JSON. Requires read:sessions (broad read or account_owner
also work).
Captures are kept only briefly: at most the 20 most recent per session, each
for no more than 30 minutes after it was taken, and none after the session
ends. Fetch them as soon as the turn ends. An unknown or expired
captureId, or a session you cannot access, returns 404.
curl -sS "https://api.driftstack.dev/v1/agent-sessions/$ID/captures/$CAPTURE_ID" \
-H "Authorization: Bearer $DRIFTSTACK_API_KEY" -o screenshot.png
The SDKs fetch it for you and hand back the bytes with the media type the
server gave them: agentSessions.getCapture(id, captureId) in TypeScript,
agent_sessions.get_capture(id, capture_id) in Python (sync and async), and
AgentSessions.GetCapture(ctx, id, captureID) in Go.
Live video (LiveKit)
POST /v1/agent-sessions/{id}/livekit-token
Get a LiveKit token for a WebRTC client (the dashboard, the desktop app, or any LiveKit-aware SDK) to subscribe to this session’s video stream. Watch a session live walks through connecting to the room and attaching the video track.
The token both shows the session’s screen and sends it input, so an
API key needs the write and read:sessions scopes. Broad read and
write together, or account_owner, satisfy both.
Response (200):
{
"ws_url": "wss://<livekit-host>",
"room": "agt_<uuid>",
"token": "<HS256 JWT>",
"participant_identity": "customer-<account-uuid>",
"expires_at": "<ISO-8601>"
}
Tokens are valid for 24 hours. The room name is always the agent
session id; the participant identity is customer-<account-uuid>, so
joins from the same account are deduplicated.
Customer-side grants on the minted token:
canSubscribe: true— receive the published video streamcanPublish: false— Driftstack publishes the video; you are subscriber-onlycanPublishData: true— used to send input events to the session
Auto-populated on session-create. When live video is available on the deployment,
POST /v1/agent-sessionsreturns the samelivekitshape inline on the 201 response, so clients can connect immediately after create without the explicit round-trip to this endpoint. Deployments without live video omit the field; the explicit endpoint is the fallback.
The SDKs mint it for you and return the same five fields:
agentSessions.livekitToken(id) in TypeScript,
agent_sessions.livekit_token(id) in Python (sync and async), and
AgentSessions.LivekitToken(ctx, id) in Go.
Errors:
| Status | Type | When |
|---|---|---|
| 404 | not-found | session id unknown OR caller doesn’t own it (anti-enumeration) |
| 403 | forbidden | the session is not active (provisioning, paused or closed) — only active sessions can mint a token |
| 429 | rate-limited | the account’s agent_sessions:live rate limit is used up; wait Retry-After |
| 503 | feature-unavailable | live video is not available on this deployment, or is temporarily unavailable — contact support if it persists |
Live transcript stream (SSE)
GET /v1/agent-sessions/{id}/transcript
Server-Sent Events stream that publishes every transcript append in real time. Customers building their own UIs (dashboard, desktop apps) can subscribe instead of polling.
Auth: bearer token via Authorization: Bearer <token> header
OR ?ds_token=<token> query-string fallback (EventSource API
in browsers doesn’t support custom headers; the query-string
fallback exists for that use case). Account API keys require the
read:sessions scope; broad read and account_owner credentials
satisfy that floor. A key scoped only to another resource cannot open
the stream.
Treat this as a sensitive session-history stream. Free-text user and
operator body fields are returned verbatim to authorized readers (web
addresses in them with secret parameters masked).
For structured interact:type intents, password/OTP/PIN/card/API-key
values marked sensitive: true (or inferred from a sensitive selector)
are omitted from the intent objects in SSE; a copy held in the server’s
memory for the live session only is used by Driftstack to resume the plan exactly.
The stream shows the stored (slim) form. Every entry is published as
Driftstack stores it, live and on a Last-Event-ID replay alike:
- a stored step keeps its kind and field, not the page’s words: a
navigatestep’surlis the address’s site (scheme://host[:port]/, e.g.https://shop.example.com/), the words in a selector and atext=locator are cut to…, a frame’ssrcornameis its site ora frame, and atypestep has no typedvalueand is markedsensitive: trueorvalue_omitted: true(only a saved credential’s{{credential:…}}placeholder, or an empty value for a clear, is kept); - an agent entry’s step lines name each step and its site, never page text or a typed value;
- the exact plan of a step waiting for approval is held in the server’s memory only, never stored, and does not survive a restart (see Confirming a purchase, payment or account deletion).
What that omission covers, exactly: the value field of an
interact intent, and only that field, on the step object itself —
for any action marked sensitive: true, and for a type step whose
selector names a sensitive field even when it is not marked. It also
covers, marked or not, a type step whose selector names no field (no
selector, a blank one, a stored selector whose words were taken out, or
one that names the focused element (:focus)) or does not name one
specific field: its value is kept only when the element the selector
selects carries an #id, an [id=…], [name=…], [aria-label=…] or
[placeholder=…] attribute, or the selector is a text= one; a bare
tag such as input, *, a class, a position (:nth-child) or a list
omits it. It also covers a press step whose value is not a key
name: a key that types a character, Space included, or any other
text is omitted, while key names such as Enter, Tab, Escape,
Backspace and the arrow keys are kept. The step
keeps its selector, its position in the list, and its
sensitive: true marker. A sensitive selector redacts only type: an
intent accepts sensitive on every interact action, and on tap,
press, scroll or swipe the selector omits nothing. No other
intent kind is redacted on any read surface. The same projection is applied
to the intents and results[].intent of a turn response and to a
saved recipe’s intent_log, so the three agree.
The step object is the only part of a result the omission rewrites,
apart from an omitted press’s own line (below).
summary, reason and matchedText are otherwise free text the projection
never reaches, and an agent entry’s plan summary in body is built
from those same fields. Read them as text the run produced, not as
redacted fields:
- A
typestep’s summary is written from its selector, never from its text — for exampletyped into #email. Atypestep with an emptyvaluesayscleared #email. - A
pressstep’s summary repeats the key it pressed — for examplepressed Enter— but when the step’svalueis omitted (a marked press, or a press that is not a key name) its summary, a failurereasonthat starts with that line, and an older agent entry’sbodyline for it saypressed a keyinstead. - A step held for confirmation carries the words it was held on in
matchedText. For a typed step those words can be the typed text itself, but never when the step’svalueis omitted or the step types a saved credential: then they are the page’s own words for the control, the step’sselector, or the result’scategorywhen the step has no selector. - A failed step’s
reasoncan quote the session’s own error message. If that message repeats the text of atypestep whosevalueis omitted, the text is replaced by[hidden].
This is a different flag from the sensitive on
POST /v1/sessions/:id/interact
(sessions), which only shapes typing behaviour in
the session.
Event types emitted:
transcript.entry— fires for each transcript append. Theid:SSE field is the entry’s index: an absolute number that never changes, also after older entries were dropped from the session’s live history (seehistoryunder Resource shape). The newest entry is numbertranscript_length - 1. Thedata:field is JSON with{ index, entry }whereentryis an object withrole,body,at, and optionallyintents(entries can carry other fields too; ignore any you do not recognise):role— one of'user'(customer-supplied message),'agent'(the AI’s output: plan-executed, clarify, or refuse), or'operator'(manual-mode pass-through — the customer’s own UI/script logging directly without involving the AI).body— always human-readable text, never JSON. For user and operator turns it is what was supplied. For agent turns it is a prose rendering of the AI’s outcome:refused: <reason>,clarify: <question>, a newline-joined plan summary for plan-executed turns (which may end(plan halted on failure)), or the answer text for a transcript question. Do notJSON.parseit — the structured form of a plan-executed turn isintents?below, notbody.at— ISO 8601 timestamp.intents?— present only onrole: 'agent'+ plan-executed turns; carries the structured intent list that was executed (the recipes route gathers these intointent_logsnapshots — see the recipe docs). Sensitive type intents retain their selector, ordering, andsensitive: truemarker but omitvalue.
transcript.rolled_off— sent at most once, before the replay, when the replay reaches back into entries that were dropped from the session’s live history. It has noid:field, so it does not move yourLast-Event-ID. Thedata:field is JSON with{ entries_dropped, live_from_index, first_task_index }: how many entries were dropped, the number of the first entry the agent works from apart from the first task, and the number of the first task, which is kept (nullif it was dropped too). The dropped entries then follow as normaltranscript.entryframes under their own numbers, so you lose nothing; the event only says the agent no longer works from them.transcript.error— sent once, just before the server closes the stream itself. It has noid:field. Thedata:field is JSON with{ code, detail }:detailis one sentence, andcodesays why:stalled(the stream was not taking entries fast enough: no progress for 30 seconds while entries were waiting to be sent, or the history took more than 5 minutes to send),live_queue_full(new entries passed 4,000,000 bytes while the history was still being replayed),backlog(the stream’s unread bytes passed 4,000,000 bytes),access_ended(the key that opened the stream no longer has access to the session),server_error(an error on the server’s side) orshutdown(the server is restarting). Treat a code you do not know the same way: the set may grow. The stream then closes. Reconnect withLast-Event-IDset to the last index you received: nothing is lost or repeated. If the client does not read the rest within 30 seconds (10 seconds onshutdown, when the server stops), the connection is reset instead; resume the same way.
Resume semantics (RFC 6202 + EventSource spec):
- The client’s last received id is sent back as
Last-Event-ID: <n>header on reconnect. The server replays every transcript entry with index > n, then live-streams new appends. - The replay is exclusive (strictly greater than the supplied index) so a resumed subscriber doesn’t see duplicate events.
- A new browser
EventSourcecannot send the header, so the same index may instead be given as the?last_event_id=<n>query parameter, the way?ds_token=stands in for theAuthorizationheader. It is read exactly as the header is, and the header wins whenever it is sent with a value (even a value that is not a number, which replays from the start). Rolling out: it is read only where it has been switched on; elsewhere the parameter is ignored and a new stream replays from the start. Give only an index this session’s stream sent you: a larger number also holds back new entries up to that number.
Heartbeat: server sends a : stream open comment on connect, then a
: heartbeat <ISO-8601> comment about every 30 seconds. Each heartbeat
re-checks your key, and the stream closes if the key has lost access.
Browsers’ EventSource auto-reconnect on disconnect uses
Last-Event-ID for resume, so a transient network blip doesn’t
lose any transcript content as long as the customer’s auth
token is still valid.
From a server, use the SDK rather than an SSE client of your own:
agentSessions.transcript(id, opts?) in TypeScript (an async generator),
agent_sessions.transcript(id, last_event_id=…) in Python (an iterator, and an
async iterator on AsyncDriftstack), and
AgentSessions.Transcript(ctx, id, opts, fn) in Go (a callback that returns
false to stop). Each one replays the entries already recorded, then follows
new ones; each sends Last-Event-ID for you when you pass the last index you
saw; and leaving the loop closes the connection, which matters because of the
account-wide cap below.
The SDKs never reconnect by themselves, and one call may not return a long
session’s whole history: each SDK holds one stream response to 8 MiB, and a
long session’s replay can pass that. One call may stop with a transport error
(TransportError in TypeScript and Python, *TransportError in Go) when the
stream passes the 8 MiB limit, or when the server sends transcript.error and
closes the stream (the error message names the code). Call it again with
lastEventId (last_event_id, LastEventID) set to the last index you
received: nothing is lost or repeated.
Example (TypeScript browser):
const url = new URL(`/v1/agent-sessions/${id}/transcript`, 'https://api.driftstack.dev');
url.searchParams.set('ds_token', token);
let refusals = 0;
let lastIndex = -1;
function open() {
// A new EventSource cannot send Last-Event-ID: give the last index you
// received as ?last_event_id= instead (read where it is switched on; see
// Resume semantics above), so a reopened stream carries on from it.
if (lastIndex >= 0) url.searchParams.set('last_event_id', String(lastIndex));
const stream = new EventSource(url.toString());
stream.addEventListener('open', () => {
refusals = 0;
});
stream.addEventListener('transcript.entry', (ev) => {
const { index, entry } = JSON.parse(ev.data);
lastIndex = index;
console.log(`[${index}] ${entry.role}: ${entry.body}`);
});
stream.addEventListener('error', () => {
// A stream that dropped reconnects by itself with Last-Event-ID. A stream
// that was refused (a 429) is closed for good: open a new one after the
// wait.
// A browser cannot see the status: stop after a few refusals in a row
// (a revoked key or a deleted session is refused every time).
if (stream.readyState === EventSource.CLOSED && ++refusals <= 5) setTimeout(open, 5_000);
});
}
open();
Closing the EventSource on beforeunload is the customer’s
responsibility. There is no per-session subscriber cap, but there IS
an account-wide one: at most 10 concurrent transcript streams per
account. The eleventh is refused with 429 and a Retry-After of
30 seconds, so a dashboard that opens a stream per visible session
will start shedding them once it crosses ten — across all sessions,
not per session. Each subscriber also holds a long-lived
connection.
While a stream sends history it reads from the session’s ARCHIVE (the entries
that rolled off, when your Last-Event-ID is absent or not past them), it counts
against a second limit: at most 4 such replays per account at once (16 on
one server). A stream opened past it is refused with 429 and a Retry-After
of 5 seconds; open it again after that wait. A replay ends when its history is
written, so a refused stream is let in as soon as one of the replays in flight
ends (5 minutes at most once its history starts to go out). A reconnect that resumes near the end (its
Last-Event-ID is past the rolled-off entries, as after a server restart) is
not refused by this limit (unless the session rolls off again at that moment). A browser EventSource does not retry a refused
stream by itself (its readyState becomes CLOSED): reopen it, as the example
above does.
Page state
GET /v1/agent-sessions/{id}/page-state
The latest page state the session reported — polled by the desktop app’s loading bar and error overlay, and available to your own UIs the same way.
Response (200):
{
"page_state": {
"state": "loading | loaded | errored | stalled",
"url": "https://example.com | null",
"title": "Example Domain | null",
"tabId": "<tab id> | null",
"input_focused": true,
"error": { "kind": "net", "message": "<human-readable>" },
"dialog": { "kind": "confirm", "message": "Empty your cart?" }
}
}
state: "stalled" means the page is frozen but the session is still
alive (for example, hung JavaScript) — distinct from errored
(a hard page error) and loading (a navigation in flight). error
is null except on errored states. input_focused is true while
an editable field on the page holds focus and false on blur (null
until the session reports one) — a UI can use it to show or hide an
on-screen keyboard. dialog is the JavaScript dialog the page has open
(kind is alert, confirm, prompt, beforeunload or http_auth; message the
page’s text, at most 1,000 characters; default_text a prompt’s starting
text, when it has one), null once the session reported it closed, and
absent when the session’s browser has never reported a dialog — see
Dialogs. The message is page text, returned here and never stored.
url and title stay those of the page the session is on when a report is
about something else (a dialog, a focused field). page_state is null when
nothing has been reported yet, the last report is a loading or stalled state
older than the freshness bound (a page that finished loading, or has a dialog
open, reports nothing new and is never aged out), the session is closed, or
live session state is unavailable on this deployment.
Network log
GET /v1/agent-sessions/{id}/network
The requests the running session’s pages have made — URL, method, status, and
the HTTP protocol each one used — for a request log in your own UI. Poll it,
passing after (the next_after from the previous response) to get only the
requests logged since then; omit it to read the whole recent log.
Response (200):
{
"status": "ok",
"entries": [
{
"id": "<id>",
"url": "https://example.com/",
"method": "GET",
"status": 200,
"protocol": "h2",
"started_at": 1780000000000,
"duration_ms": 84
}
],
"next_after": "42",
"epoch": "kX2v9QmT0bLr",
"dropped": 0,
"missed": 0
}
protocol is h1 (HTTP/1.1), h2 (HTTP/2) or h3 (HTTP/3), and is left out
when the browser reported none — for example a failed load or a response served
from cache. started_at is unix milliseconds, taken at the moment time_basis
names: completion (when the load finished) or start; when time_basis is
absent, read it as completion. Entries can also carry alpn, type,
initiator, size_bytes, from_cache and page_id. page_id is the
browser’s own number for the page the request belongs to — a counter kept per
session, so you can group a session’s requests by page; it is not an identifier
of you or of the site, and an entry reported without one has no page_id
(absent, never 0 or null). source, when present, says which of the
browser’s two recording paths reported the request: marker (the in-page path)
or channel (the browser’s native channel). It describes how the entry was
recorded, not the request itself; an entry reported without one, or with any
other value, has no source. When the session is not running, or
network logging is not available on the deployment, status is unavailable,
entries is empty, next_after is null, and reason says why.
An ok response also says what it could not return. dropped counts requests
reported for this log that could not be kept, for example one larger than the
per-request limit. missed counts requests newer than your after that the log
had already removed to stay within its size limit. epoch names the log your
cursor belongs to: if it differs from the one you last saw, the log was started
again, so read it again without after. It is an opaque value; compare it only
for equality.
The url is masked before it is held: the user name and password in an
address, and the values of credential-named query parameters (token,
access_token, password, api_key, a code beside a state, and the like)
are replaced by %5Bhidden%5D. redacted_query names the keys whose values
were masked, and redacted_userinfo is true when the address carried a user
name or password. Each row also carries an entry_id for the request-detail
call below and has_detail, whether that call returns a detail for it; when it
does not, detail_state says why.
Request detail
GET /v1/agent-sessions/{id}/network/{entryId}
Request detail is off by default. While it is off, this call answers
detail: null with detail_state: "disabled" for every row, and the session
does not collect headers or request bodies at all.
When a deployment turns it on, the call returns, for one row of the network log
(entryId is the row’s entry_id), what the browser reported about that
request: the request headers and the response headers; the request body a page
sent, as form fields, JSON text, multipart parts or text; the response’s content
type and sizes; how long the request spent queued, in name lookup, connecting,
securing the connection, waiting for the first byte and downloading; and the
redirects that led to the final address. Response bodies are never reported.
Hidden values are replaced, not kept. A cookie, authorization or other
credential header is { "name": "cookie", "redacted": true } with no value
(a hidden cookie header carries count, its number of cookies). A password-like
or payment-card field in a form is { "name": "password", "redacted": true }.
In JSON text the value is replaced by "[hidden]" and its JSON pointer listed
in hidden_paths; in a text body or an address the marker is [hidden]. A
file part of a multipart body is its name, type and size only; a binary body is
its type and size only. A body longer than 16,384 characters is cut and marked
truncated.
Response (200):
{
"status": "ok",
"entry": {
"entry_id": "kX2v9QmT0bLr.42",
"has_detail": true,
"url": "https://example.com/login",
"method": "POST",
"status": 302,
"started_at": 1780000000000
},
"detail": {
"request_headers": [
{ "name": "content-type", "value": "application/x-www-form-urlencoded", "redacted": false },
{ "name": "cookie", "redacted": true, "count": 2 }
],
"request_headers_basis": "wire",
"request_body": {
"kind": "form",
"content_type": "application/x-www-form-urlencoded",
"size": 71,
"truncated": false,
"fields": [
{ "name": "email", "value": "person@example.com", "redacted": false },
{ "name": "password", "redacted": true }
],
"hidden_count": 1
},
"response_headers": [{ "name": "location", "value": "/account", "redacted": false }],
"response": { "status_text": "Found" },
"timing": { "wait": 201, "download": 0.4, "total": 233.5, "reused_connection": true }
}
}
detail is null with a detail_state when there is nothing to return for a
held row: disabled (off on this deployment), not_reported (this browser
build reports the address, status and size only), not_captured (the Mac running the
session was busy), rejected (the detail could not be read), over_limit (it was larger
than the per-request limit) or evicted (it is no longer held; details are
kept for the most recent requests within a memory limit). status is gone
when the request is no longer in the log or the entry_id belongs to an
earlier log, and unavailable when the session is not running. An entryId
that is not an entry_id is a 400. Nothing here is stored: it is held in memory
only while the session runs, and the response tells browsers and proxies not to
cache it.
Read the cookie jar
GET /v1/agent-sessions/{id}/cookies
Pulls the running session’s full live cookie jar — including
httpOnly cookies — from the session’s browser.
Response (200), discriminated:
{
"cookies": [
{
"domain": "example.com",
"name": "session",
"value": "…",
"path": "/",
"expires": 1780000000000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"status": "ok"
}
domain, name, and value are always present; path, expires
(unix milliseconds; null or omitted for session cookies),
httpOnly, secure,
and sameSite (Strict | Lax | None) appear when the store reports
them. On unavailable / timeout / error, cookies is null.
The jar shape round-trips 1:1 into Import cookies
below — you can save the cookies array to a file and re-import it
into a later session.
Import cookies
POST /v1/agent-sessions/{id}/cookies/set
{ "cookies": [{ "domain": "example.com", "name": "session", "value": "…" }] }
The write-twin of the read above: relays a cookie jar (the exact
shape the read emits, 1 to 2000 cookies per request) into the
running session’s cookie store. Response (200) is the discriminated
{ "status": …, "reason"?: … } shape — ok means the write was
applied to every cookie that was not skipped. On unavailable or
error nothing was written, except when the reason says the import
did not finish in time: then some or all of the cookies may have been
set, so read the cookies back before importing again. On timeout the
session did not answer in time, so
the write may or may not have landed: read the cookies back before
importing again. Importing the same cookie again overwrites it.
A cookie with an empty domain makes the whole request a 400;
nothing is written. The error names the first such cookie by its
position in the array (counting from 0) and how many more there are.
A partitioned cookie (see below) with an empty domain is still
skipped and counted; an empty domain there does not make the request
a 400.
When the session reports them, the body also carries the import’s
counts: imported (cookies written and, when verified is true, found on
read-back), skipped (cookies not imported: not
written, or dropped: written but missing on read-back; partitioned
cookies are not in it, they are counted in skipped_partitioned), skipped_by
(invalid, expired, duplicate, time_limit, dropped) and
verified (whether the session read the cookies back to check). A
count the session did not report is left out, never sent as 0, and
imported is always left out when the import did not finish in time.
After an error where the session’s write itself failed, imported is
0 and skipped counts only the cookies refused before the write, so
imported, skipped and skipped_partitioned can add up to less than
the jar.
Partitioned (CHIPS) cookies are not imported yet. A cookie whose
partitioned is anything other than false or null, or whose
partitionKey is anything other than null or "" (a string, or an
object such as Chrome’s { "topLevelSite": … }), is skipped and never reaches the
session; any other value of these two fields never fails the request.
On ok, timeout and error the response counts the skipped cookies:
{
"status": "ok",
"skipped_partitioned": 2,
"skipped_partitioned_complete": true,
"notice": "2 cookies were not imported because they belong to a partitioned (third-party) context; this browser does not support importing them yet."
}
notice is present only when skipped_partitioned is above 0.
skipped_partitioned_complete is false when the session’s browser did
not report its own count (an older browser build, or no reply), so the number
covers only the cookies the API skipped. A jar whose every cookie is
partitioned answers ok with nothing written.
Each session takes a cookie jar up to its own size, counted as JSON:
about 3.9 MiB unless the session takes more. A larger jar is refused with a
413 and nothing is written — detail says “This cookie jar is too large for
this session (limit …)”, and the limit_bytes and size_bytes
extensions carry the two sizes. A request body over 8 MiB is also a 413.
| Status | Type | When |
|---|---|---|
| 400 | validation-failed | The body is not 1 to 2000 cookies, or a cookie that is not partitioned has an empty domain. The whole jar is refused; nothing was written. |
| 413 | payload-too-large | The jar is larger than the session takes, or the body is over 8 MiB. Nothing was written. |
Read the profile’s notes
GET /v1/agent-sessions/{id}/profile-notes
The free-text notes kept with the profile this session was launched
from — the same notes field GET /v1/profiles/{id} returns. The
desktop app’s session window shows and edits them under Notes;
this route is how it reads them with only its session key. An API key
needs read:profiles, which broad read covers.
{ "profile_id": "prof_…", "notes": "Signed in as the test shopper. Cart has two items." }
A session launched without a profile answers { "profile_id": null, "notes": null }. notes is null until something is written.
Replace the profile’s notes
PUT /v1/agent-sessions/{id}/profile-notes
{ "notes": "Signed in as the test shopper. Cart has two items." }
Replaces the notes on the profile itself — PATCH /v1/profiles/{id}
with notes writes the same field, and every later launch of the
profile reads it. Plain text, UTF-8, at most 16 KiB; null clears
them. Response (200) is the same { "profile_id", "notes" } shape as
the read. An API key needs write:profiles, which broad write
covers; this is the one documented agent-session write that takes a granular scope.
| Status | Type | When |
|---|---|---|
| 400 | validation-failed | notes is missing, not a string, or over 16 KiB of UTF-8. Nothing was written. |
| 409 | conflict | The session was not launched from a profile, so there is nowhere to keep notes. |
Step browser history
POST /v1/agent-sessions/{id}/history
{ "direction": "back" }
Steps the running session’s back-forward list one entry in
direction ("back" or "forward") — what the desktop app’s
back/forward buttons call. The session’s current tab is stepped.
tabIdis not supported yet. Devices step the current tab only, so sendingtabIdis rejected with a422rather than silently stepping a different tab than you asked for. To step a specific tab, activate it first.
The response (200) is the discriminated { "status": …, "reason"?: … }
shape:
status |
What happened |
|---|---|
ok |
The step was applied. |
unavailable |
The step was not sent: history steps are not available here, or the session is not running or cannot be reached. reason says which. |
timeout |
No answer arrived in time. The step may still be applied, so check the page before sending it again. |
error |
The step was not applied. reason says why, in a sentence you can show as it is. |
Four error answers are about the session rather than the step:
- This session stopped answering the steps sent to it. It had already
failed to answer one (an earlier step got no answer in time, or the
browser’s own check on the page got no answer), and a quick check sent just
before this step got no answer either, so the step was not sent. The
live view may still show the page.
reasonsays to end the session and launch a new one; sending the step again will not help. - This session was slow to answer. It did answer that quick check, but so
slowly that too little of the request’s time was left for the step, so the
step was not sent.
reasonsays to try again. - The browser was still busy with another action. A session runs one
action at a time, so this step waits while the AI agent’s current action,
or an earlier back or forward, is still running. When that does not finish
within half of the request’s time, the step was not sent.
reasonsays to try again once the other action has finished. - The browser needed a quick check, and no time was left for it. A
browser that had already failed to answer gets that quick check before
each step. When this step first waited for another action to finish, too
little of the request’s time was left for the check and the step, so the
step was not sent.
reasonsays to try again.
Tabs
POST /v1/agent-sessions/{id}/tabs
{ "action": "open", "url": "https://mail.example.com/" }
The other bodies: { "action": "switch", "tab_id": "t1" },
{ "action": "close", "tab_id": "t2" } and { "action": "list" }.
Opens a page in a new tab, brings another tab to the front, closes a tab, or lists the session’s tabs, as a person does in Safari on the iPhone. Every other call (a list of steps, a message, a history step) acts on the tab in front.
openloadsurl(anhttp://orhttps://address) in a new tab and brings that tab to the front. The tab you were on stays open behind it. An address a navigate step may not open is refused the same way (400, namingurl): this computer, a private network, a name only a private network answers (one with no dot, such ashttp://intranet/, one with an empty label, such ashttp://a..b/, one ending.lan,.corp,.svcor another private-use suffix, or anip.io-style name for a private address or for none), or a user name and password. Your first tab call names the tab the session was already showing. New tab ids look liket48213. A session holds at most 8 tabs.switchbrings that tab to the front (the tab already in front: nothing is sent,reloaded: false). A tab the browser kept in memory comes back as you left it. A tab the browser had to unload, which happens when three or more tabs are open, is loaded again from its address. A tab the session cannot bring back (it never showed a web address, or its address held a secret and the server restarted since) answers409 tab_not_reloadable; close it instead.closecloses that tab. When it is the tab in front, the tab to its right comes to the front first (the one to its left when it was the last). The only open tab cannot be closed.listchanges nothing.
On a session that runs a saved profile, all four actions work. The
session starts on the profile’s active tab (or on initial_url when you pass
one). The profile’s other saved tabs are not opened in the session and are
not listed here, so switch and close cannot reach them. When the profile
is saved at the end of the session, the tabs open in it are added to the
profile’s saved tabs: a tab saved by an earlier session stays saved, also when
this session closes the tab it started on, and a tab opened and closed again in
this session is not saved. Past 64 saved tabs, the tabs used longest ago are
dropped. If a person takes control in the desktop app, the app’s tab bar
decides instead (see below).
The response (200) when the call worked:
{
"ok": true,
"action": "open",
"tab_id": "t48213",
"active_tab_id": "t48213",
"tab_count": 2,
"tabs": [
{ "tab_id": "t48212", "url": "https://shop.example.com/", "title": "Shop", "active": false },
{ "tab_id": "t48213", "url": "https://mail.example.com/", "title": "", "active": true }
]
}
A tab call waits up to 30 seconds for the tab to be in front: the session
says so at once for a tab it kept in memory, and for a tab it loads, once
it has opened the tab’s address in it. When that has not happened yet, the answer
has "pending": true (with active_tab_id the tab being brought to the
front). Until it is done, a call that would send the session something (a
tab call, a list of steps, a message, a history step) answers
409 tab_switch_pending; list answers with "pending": true until then.
A background tab’s url and title are what they were when it was last in
front; titles are kept in memory only, so they read empty after the server
restarts.
Each tab call, list too, counts against its own agent_sessions:tabs rate
limit, with the same figures as the agent_sessions:steps limit
POST /v1/agent-sessions/{id}/steps draws on: a tab call runs no AI, so it
does not use up your messages, and tab calls and lists of steps do not use up
each other. Past the limit the call is
refused with 429 rate-limited and a Retry-After header, as a list of steps
is, and nothing is sent to the session (see Rate limits).
A 200 with "ok": false means the call reached the session:
error |
What happened |
|---|---|
device_error |
The session refused before changing anything, or (closing the tab in front) moved to the next tab but could not close this one; reason says which. tabs is the list as it is now. |
page_load_failed |
The page could not be loaded in that tab, so the session went back to the tab it was on. The tab list is no longer known (see below). |
switch_failed |
The session could not bring that tab to the front. The tab list is no longer known (see below). |
outcome_unknown |
It is not known whether the tab was opened, switched or closed (the session did not confirm it in time, or the answer could not be recorded). The tab list is no longer known (see below). |
Refusals (nothing was sent to the session):
| Status | code |
When |
|---|---|---|
| 400 | (validation) | The body is wrong: open needs a url and no tab_id; switch and close need a tab_id; list takes neither. An address on this computer, a private network, or with a user name and password is refused. |
| 404 | No such session for your key, or tabs are not on for your account. | |
| 409 | tab_unknown |
No open tab has that id. |
| 409 | tab_list_unknown |
The tab list is no longer known (see below). |
| 409 | last_tab |
The only open tab cannot be closed. |
| 409 | tab_not_reloadable |
The session cannot bring that tab back (see switch above); close it instead. |
| 409 | tab_switch_pending |
A tab is still being brought to the front. List the tabs to see when it is done. |
| 409 | tab_limit |
The session already has 8 tabs. |
| 409 | (turn_in_progress) |
A message or a list of steps is running on this session. Try again when it has finished. |
| 409 | session_busy |
Another action on the session did not finish in time. |
| 409 | relay_busy |
Too many requests for this account are waiting on sessions. Try again shortly. |
| 409 | session_unresponsive |
The session stopped answering its last action. |
| 409 | (ai_control_unavailable) |
A person has control of the session. |
| 503 | session_not_running, session_unreachable, tab_state_unavailable, tabs_unavailable |
The session is not running, cannot be reached, or its tab list could not be read. Nothing was sent. Try again. |
| 503 | front_tab_unknown |
A new tab list is needed and the session has not yet said which tab it shows. Nothing was sent. Send a step that loads a page, then try again. |
When the tab list is no longer known. After a person has had control of
the session, or after a tab call that failed or was not confirmed, the
session may have tabs the list does not know. The next tab call starts a new list from the
tab in front (the answer says "list_restarted": true), and switch or
close of a tab from the old list answers 409 tab_list_unknown (or
tab_unknown once a call has restarted the list). The next open sends the
new list to the session, and the session closes its other tabs then.
- If a person takes control in the desktop app, the desktop app’s own tab bar replaces the session’s tabs: tabs opened through this route may close.
- While two or more tabs are open, a list of steps’
navigatedoes not report the page’s HTTP status or a load failure. - A tab that a page opens itself (a link with
target="_blank",window.open) is not in the list. - On a session that runs a profile, the open tabs are saved with the profile when the session ends (see above). The desktop app shows the saved tabs as tabs when it opens the profile; an API session starts on the profile’s active tab only.
Upload a file
POST /v1/agent-sessions/{id}/files
{ "name": "invoice.pdf", "mime": "application/pdf", "dataB64": "<base64 bytes>" }
Uploads a file into the running session’s isolated upload area
and returns a handle for it. The decoded
size is capped at 64 MiB per file (larger, or an empty file, is
a 400); per-account concurrent upload volume (512 MiB) and
per-session lifetime totals (2 GiB) are also capped — an over-cap
request returns status: "error" with the cap named in reason.
Each session takes a file up to its own size, so the largest file a session
accepts is usually smaller than 64 MiB:
about 2.9 MiB unless the session takes more. The session read’s upload_max_file_bytes gives the exact figure
for the session; check a file against it before reading it. A larger
file is refused with a 413 and nothing is sent: detail says “This file is
too large for this session (limit …)”, and the limit_bytes (the same
figure as upload_max_file_bytes) and size_bytes extensions carry the two
sizes. A request body over 96 MiB is also a 413.
| Status | Type | When |
|---|---|---|
| 400 | bad-request | The file is empty, or its decoded size is over 64 MiB. |
| 413 | payload-too-large | The file is larger than the session takes, or the body is over 96 MiB. Nothing was sent. |
Response (200), discriminated:
{
"handle": { "id": "<opaque>", "name": "invoice.pdf", "mime": "application/pdf", "size": 182044 },
"status": "ok"
}
handle is an opaque reference — file paths on the Mac running the
session are never exposed. On any non-ok status, handle is null.
On status: "error", code names two failures a program can act on, and
reason says the same in a sentence. Nothing was stored in either case:
upload_origin_changed— the session’s page moved to another site after the upload started, so the file was not kept. Upload it again on the page that needs it.upload_no_origin— the session’s page has no web address yet (its start page), so the upload cannot be tied to a site. Open the page you want to upload to first, then upload the file.
More codes may be added, so branch on the ones you know and show reason
otherwise. Any other failure has no code; read reason.
{
"handle": null,
"status": "error",
"reason": "Open the page you want to upload to first, then upload the file — nothing was stored.",
"code": "upload_no_origin"
}
To upload a file to a page: upload it here first, then tap the page’s
upload button (for example with an interact tap on the visible button).
The session’s browser gives the page the newest unused file uploaded on
the same web address as the page (scheme, host and port must match:
www.example.com and example.com count as different), once. A second
tap on a single-file input gets the next-older unused file; a file input
that takes several files gets every unused one. Upload the file while
the page that needs it is open, since an upload is tied to the web
address the session is on. There is no iPhone-style chooser yet, and the
handle id does not pick a file; the order of uploads does. Files you
upload are for this session only: they stay in the session’s upload area
on the Mac running it, are never copied to storage, and are deleted when
the session ends.
List downloads
GET /v1/agent-sessions/{id}/downloads
Lists files a page downloaded in the running session.
Downloads from pages are not available yet: the session’s browser
does not save a page’s downloads today, so this list stays empty
(files: []) and there is nothing to fetch. When downloads land, they
will go to a per-session isolated area on the Mac running the session,
never a shared folder. Response (200), discriminated:
{
"files": [{ "name": "report.csv", "size": 51234, "mime": "text/csv" }],
"status": "ok"
}
files: [] with status: "ok" means no downloads yet. name is
always a bare basename, never a path; mime appears when the session
reports one. On any non-ok status, files is null.
Fetch a download
GET /v1/agent-sessions/{id}/downloads/content?name=report.csv
Fetches one downloaded file’s bytes by name (a basename from the
list above; the server re-sanitizes it and confines the read to the
session’s download area). Fetches are capped at 64 MiB. Response
(200), discriminated:
{
"file": { "name": "report.csv", "mime": "text/csv", "dataB64": "<base64 bytes>" },
"status": "ok"
}
mime falls back to application/octet-stream when the session did
not report one. A missing or too-large file is status: "error"
with the cause in reason; on any non-ok status, file is
null.
Add format=binary to get the file’s raw bytes instead
(application/octet-stream, same 64 MiB cap) — simpler and smaller than
decoding dataB64. Outcomes other than success still come back as the small
JSON envelope above with file: null. Downloads can be listed and fetched only
while the session is open, so fetch them before you close it.