Drıftstack docs
Docs

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:

  1. Give every secret you register a label that is unique within the session (for example card-<your order id>).
  2. After a failed or missing answer, list what the session holds: GET /v1/agent-sessions/{id}/secrets.
  3. 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 with DELETE /v1/agent-sessions/{id}/secrets/{handle}.
  4. 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 stream
  • canPublish: false — Driftstack publishes the video; you are subscriber-only
  • canPublishData: 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-sessions returns the same livekit shape 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 navigate step’s url is the address’s site (scheme://host[:port]/, e.g. https://shop.example.com/), the words in a selector and a text= locator are cut to …, a frame’s src or name is its site or a frame, and a type step has no typed value and is marked sensitive: true or value_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 type step’s summary is written from its selector, never from its text — for example typed into #email. A type step with an empty value says cleared #email.
  • A press step’s summary repeats the key it pressed — for example pressed Enter — but when the step’s value is omitted (a marked press, or a press that is not a key name) its summary, a failure reason that starts with that line, and an older agent entry’s body line for it say pressed a key instead.
  • 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’s value is omitted or the step types a saved credential: then they are the page’s own words for the control, the step’s selector, or the result’s category when the step has no selector.
  • A failed step’s reason can quote the session’s own error message. If that message repeats the text of a type step whose value is 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. The id: 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 (see history under Resource shape). The newest entry is number transcript_length - 1. The data: field is JSON with { index, entry } where entry is an object with role, body, at, and optionally intents (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 not JSON.parse it — the structured form of a plan-executed turn is intents? below, not body.
    • at — ISO 8601 timestamp.
    • intents? — present only on role: 'agent' + plan-executed turns; carries the structured intent list that was executed (the recipes route gathers these into intent_log snapshots — see the recipe docs). Sensitive type intents retain their selector, ordering, and sensitive: true marker but omit value.
  • 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 no id: field, so it does not move your Last-Event-ID. The data: 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 (null if it was dropped too). The dropped entries then follow as normal transcript.entry frames 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 no id: field. The data: field is JSON with { code, detail }: detail is one sentence, and code says 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) or shutdown (the server is restarting). Treat a code you do not know the same way: the set may grow. The stream then closes. Reconnect with Last-Event-ID set to the last index you received: nothing is lost or repeated. If the client does not read the rest within 30 seconds (10 seconds on shutdown, 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 EventSource cannot 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 the Authorization header. 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.

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.

tabId is not supported yet. Devices step the current tab only, so sending tabId is rejected with a 422 rather 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. reason says 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. reason says 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. reason says 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. reason says 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.

  • open loads url (an http:// or https:// 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, naming url): this computer, a private network, a name only a private network answers (one with no dot, such as http://intranet/, one with an empty label, such as http://a..b/, one ending .lan, .corp, .svc or another private-use suffix, or a nip.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 like t48213. A session holds at most 8 tabs.
  • switch brings 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) answers 409 tab_not_reloadable; close it instead.
  • close closes 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.
  • list changes 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’ navigate does 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.