Drıftstack docs
Docs

Agent sessions

An agent session lets an AI drive a browser session from plain-language messages. The customer sends a message ("open https://example.com and capture a screenshot"); Driftstack turns it into a list of typed intents (navigate, interact, wait, capture, the behavioural scroll and behavioral_pause, the three moves for a page that resists a plain plan: back, extract and tap_at, and dialog, which answers a JavaScript dialog the page has open — see Dialogs), runs them, and returns the results in the response.

back goes one step back in the browser’s history. extract reads the rendered text of one element (selector) or of the whole page (body: true); the text comes back, bounded, in the step’s summary. With a selector, attribute reads that attribute of the element instead of its text — a link’s href, an image’s src, a data-… or aria-… value — and the summary says which it was: extracted attribute href from #terms: https://example.com/terms. An element without the attribute is said to have none (extracted nothing: the element #terms has no href attribute), which is not the same answer as a selector that matched nothing. attribute is a name of letters, digits, -, _, : and . (at most 128 characters), and it is never used with body; inside an embedded frame it is read only on a session whose browser can read an element there (see below).

What a field holds now is a property, not an attribute. An attribute is what the page’s markup said: attribute: "value" on a field someone typed into reads the markup’s starting value, usually nothing. property reads what the element holds now — { "kind": "extract", "selector": "#email", "property": "value" } — and is one of value, checked, selected, selectedIndex, outerHTML, innerHTML, textContent, name, id, type, autocomplete, placeholder, disabled, readOnly and required, with a selector only, never with attribute or body (a list of steps naming any other property, or one beside attribute or body, is a 400 naming the field). The result carries what it read in value: a string; a boolean for checked, selected, disabled, readOnly and required; a number for selectedIndex (-1 when nothing is chosen); and null when the element the selector matched has no such property — the summary says so in words, so null is never confused with an empty string. textContent of the whole page (body, html or :root) is every text node in it, including text the page never shows — a noscript fallback, script and style text — as in any browser; in a list of steps you send, its summary says so when the selector is exactly body, html, :root, html > body, html>body or html body (any letter case), and an extract with no property (the rendered text, like innerText) reads what the page shows. value_truncated is true when a string was cut to its first 100,000 characters (UTF-16 code units). The value is returned in this response and never stored: the summary never quotes it, the transcript and the Idempotency-Key receipt keep the step without it (a replay answers the step with no value), and no log line carries it. A saved credential is never read back: the field holds the real value, but what Driftstack typed from your vault or a session secret reads back, in any read, as its placeholder ({{credential:cred_…}} or {{credential:sec_…}}), and the result then carries shows_placeholder: true. Check that flag before you compare a read-back with what you expected: a field that keeps only digits does not read back the stored number, and the digits in the placeholder are the handle’s. A value you typed yourself reads back as typed — unless it is the value of a saved credential on your account, which is typed as that credential and reads back as its placeholder too. shows_placeholder is absent on every other result, including a placeholder the page wrote itself. Once the session can no longer recognise a saved credential it typed (you deleted it since), no text any extract reads is shown — the summary says it was not read back and value is null. A result that could not be checked in time is withheld. Once a saved credential or session secret has been typed in a session, every step result is checked for it before it is returned. In rare cases that check does not finish in time, and the step’s text is withheld: the result carries withheld: true, its summary is (not shown: this text could not be checked in time), and a property read’s value is null (with value_truncated: false and no shows_placeholder); a text, attribute or whole-page read has no value, only that summary. That null says nothing about the page: check withheld before reading value. A read tried again usually returns its text; a step that acted on the page did run, so do not repeat it for this. withheld is not set on a failed step whose reason is that same note, nor on a read not shown because its saved credential was deleted (above). A property is read only on a session whose browser can read one; on any other the step fails without being sent ("not run: this browser cannot read a field’s live value yet, so nothing was read") — it is never read as an attribute instead. While the session’s browser cannot be reached (right after an API restart, for example) such a read outside an embedded frame is not answered that way: it fails as a step that could not be sent, retryable (see When a step could not be sent to the session’s browser).

A read by selector that matched no element fails. An extract by selector — its text, an attribute or a property, on the page or inside a frame — whose selector matched nothing is a failed step with diagnosis.category element_not_found and a reason naming the selector and, inside a frame, the frame (no element matched `input[name=cvc]` in embedded frame [0], so nothing was read). It is never a success with nothing in it.

Every match of a selector: all: true. In a list of steps you send, an extract with all: true reads every element its selector matches, in page order, instead of the first — each element’s rendered text, or with attribute that attribute of each (a list of links’ href):

{ "kind": "extract", "selector": "li.result h3", "all": true }

The result carries them in values, one entry per element: a string, or null for an element without the attribute. At most 200 come back; when the selector matched more, values holds the first 200 and truncated is true, and so it is when an entry was cut to its first 100,000 characters (UTF-16 code units), or when the entries together reached 100,000 characters (the entry that reached it is cut there and the ones after it are left out); the summary says which (extracted text from 312 elements matching li.result h3 (the first 200), returned in this step’s values, never stored). The browser reads at most the first 1,000 matches, so a count of 1,000 is said as “1,000 or more”. The values are returned in this response and never stored, as a property read’s value is: this response’s summary says only how many were read, and the transcript, the Idempotency-Key receipt (a replay answers the step with no values, and its summary names the step, not the count), the partial_results of an error and every log line carry the step without them. A saved credential reads back as its placeholder, with shows_placeholder: true; once the session can no longer recognise a saved credential it typed, or the read could not be checked for one in time, every entry is null and the summary says why. "all": true goes with a selector only (and optionally attribute), never with body or property, and all set to anything but true, false or null is refused: an extract that breaks one of these is a 400 naming the field (steps.1.property). "all": false or null is the same as no all: it reads one match. Only an extract reads all; on any other step it is ignored, as every field a step’s kind does not take is (see Run a list of steps). A selector that matches nothing fails like any read by selector. Inside an embedded frame it is read on a session whose browser can read an element there. The AI does not use all in a message’s turn: it reads a page through its own look at it.

One match by its position: index. An extract with index: n (a whole number from 0 to 999) reads the element at that position among every element its selector matches, counting from 0 in page order — index: 0 is the first, as with no index at all — and answers as a read of one element does: its text (or its attribute) in the summary, which names the position (extracted text from match 2 of li.result h3: Gamma). A position the selector does not reach fails the step with element_not_found and a reason saying how many matched (no match at index 4: `li.result h3` matched 4 elements (index counts from 0), so nothing was read). index counts every match of the selector in the whole page, whatever its parent; :nth-of-type() in a selector counts only among an element’s siblings of the same tag (see Choosing the n-th element). index goes with a selector only (and optionally attribute), never with "all": true, body or property: an extract that breaks one of these is a 400 naming the field (steps.1.index); "all": false beside it is the same as no all. Only an extract reads index; on any other step it is a 400 naming the field (steps.1.index), and nothing is sent.

How many matched: count. A successful extract by selector with "all": true, index, attribute or property also carries count: how many elements the selector matched, as a number ("count": 312), in the page or in the embedded frame the step named. The browser counts at most the first 1,000 matches, so 1000 means 1,000 or more. A plain text read of one element (no all, index, attribute or property) and a body: true read carry no count: the browser’s answer to those does not say how many matched. A read that matched nothing fails with element_not_found, so a count is never zero. Like values, it is returned in this response and never stored: the transcript and a replay of an Idempotency-Key carry the step without it. Whether an element is visible is not returned as a field yet; a short selector_visible wait answers it.

tap_at taps a point on the screen (x, y in viewport pixels from the top-left corner): the AI may only take the point off the most recent screenshot it was shown, a point outside that screenshot is refused before it reaches the session, a session that cannot report what is at the point refuses it ("not supported on this device"), and the step’s summary names what was under the tap. The screenshot the AI looks at to choose the point is taken for that one planning step and is not stored — it is not a capture and has no captureId.

In a list of steps you send, a tap_at needs no screenshot: you chose the point, so it is sent as written, even straight after a navigate — which is what lets an AI run’s tap_at steps be replayed. The session still refuses a point off the screen or on nothing (the step fails and nothing is tapped), and when a screenshot of the page was taken in the session since it last changed, a point outside that screenshot is refused before it is sent, as for the AI. Steps you send are approved for a purchase, payment or account deletion exactly as before.

Read results first. Every step the turn ran, and the one it stopped at, comes back in its results, with the step itself in results[i].intent (the selector it acted on, the text it typed) and what happened: a summary for a step that succeeded, a reason for one that failed. That is the record of what was done on the page; answer is a sentence for a person. See Step results are the record.

Your message goes to the model word for word. It is also kept in the session’s history. Never write a password, a one-time code or a card number in it. Register a password or a one-time code as a session secret or save it as a saved credential; a card number can only be a session secret. Then write its placeholder ({{credential:cred_…}} or {{credential:sec_…}}) instead. The model plans with the handle, not the value.

An interact step with tap names its target by a CSS selector, and the session looks that selector up when the step runs, so the tap lands on whatever the selector names at that moment rather than on a point that may have moved. The AI is told to tap a control by its selector whenever the page offers one, and to use tap_at only for something it does not. A control with no id or name of its own is given a selector by where it sits in the page (#slots > li:nth-of-type(3)). When the session can say which element the selector resolved to, the step’s summary names it after the selector — its type and its label as the page shows it, on one line and cut to 60 characters:

{
  "kind": "success",
  "intent": { "kind": "interact", "action": "tap", "selector": "#slots > li:nth-of-type(3)" },
  "summary": "tapped #slots > li:nth-of-type(3): the button “11:00”"
}

Only a control is named by its label: a button, link, field, menu, option, checkbox or image. Anything else, such as a paragraph or the text inside an editor, is named only as a control, without its words. A tap_at step’s summary names what was under the tap the same way.

Choosing the n-th element

Two ways to name “the third result”, and they count differently:

  • index on an extract counts every element the selector matches in the whole page, in page order, from 0: { "kind": "extract", "selector": "li.result h3", "index": 2 } reads the third h3 that li.result h3 matches, wherever each sits.
  • :nth-of-type(n) in a selector counts from 1, and only among an element’s siblings — the children of the same parent — that have the same tag. li:nth-of-type(3) is every li that is the third li child of its parent, so on a page with two lists it matches one element in each, and a div or h2 between the lis is not counted. A class does not change what is counted: li.result:nth-of-type(3) matches an li that is the third li among its siblings and also has the class result — not the third .result. :nth-child(n) counts every sibling, whatever its tag.

To read the third result of a list, prefer index on the selector that names every result; to tap it, name its position inside its own parent (#results > li:nth-of-type(3) a), or read the list first with all: true and choose a selector from what it holds. A selector the session gave a control by where it sits (#slots > li:nth-of-type(3)) uses the sibling count.

Driving by selector

Ask for a selector, then act on it. Two calls:

// POST /v1/agent-sessions/{id}/message
{ "user_message": "What is the selector of the Pay button?" }
// → "answer": "The Pay button's selector is #pay." (for example)

// POST /v1/agent-sessions/{id}/steps
{ "steps": [{ "kind": "interact", "action": "tap", "selector": "#pay" }] }

The second call taps #pay with no AI planning (see Run a list of steps). A message works too: { "user_message": "tap #pay" }. Either way, when the session can say what the selector resolved to, the step’s summary names it (tapped #pay: the button “Pay”), and a tap recognised as a payment stops for your approval first (Confirming a purchase, payment or account deletion).

You can name the element yourself. A CSS selector in your message is used exactly as you wrote it, even when the page list the AI is shown names the element another way or does not list it. That holds on the page itself: inside an embedded frame, a tap or typing is sent only for a selector the list showed under that frame, and a selector the list showed only inside a frame is never used on the page itself; either is refused without being sent. One exception: an id or a class that is no CSS name (such as one that starts with a digit, as in #1abc or .1x, or carries a dotted number, as in #section-1.2) is sent as the attribute match a browser reads, #1abc as [id="1abc"] and .1x as [class~="1x"], and a tap or a type that then finds nothing says so in its reason ((read as [id="1abc"])). It is still checked: an invalid selector fails without being sent, and every refusal still applies — a native drop-down list is never tapped (on a session whose browser has the iPhone’s list picker it is chosen from by a type step naming the option; otherwise it cannot be chosen from), a control the page does not display cannot be tapped, a saved credential is typed only on its own site, and a step that pays, buys or deletes an account waits for your approval.

{ "user_message": "tap #accept-terms, then read the href of a.terms" }

The turn’s intents carry your selectors as they were sent: unchanged, but for an id or a class read as an attribute match (above):

[
  { "kind": "interact", "action": "tap", "selector": "#accept-terms" },
  { "kind": "extract", "selector": "a.terms", "attribute": "href" }
]

To find a selector, ask for it: “what is the selector of the Pay button?” or “list every button on this page with its selector”. The answer is read from the rows the AI was shown for the page that is open (#pay · button · "Pay"), so it names a selector the session can act on, quoted exactly. Those rows are given to the answer when your message asks about selectors or controls (it says selector, CSS, button, link, field or element, or names a selector such as #pay); otherwise only when they fit beside all of the page’s text, so they never cut short the text another question is answered from. An answer keeps its line breaks, so a list asked for one control per line comes back one per line (the transcript entry holds the same text on one line). It is at most 4,000 characters; a longer one is cut after its last whole line and says so on a line of its own. Limits: a control inside a shadow root is not in the page source the rows are usually read from, so it may have no row and no selector to report (name it yourself with a >>> path, which the session follows into open shadow roots — a wait for selector_visible follows one too, and waits until the element it ends on is shown); a control inside an embedded frame is listed under its frame instead; and a native drop-down list (select) has a row, and is chosen from only on a session whose browser has the iPhone’s list picker.

Choosing from a native drop-down list. On a session whose browser has the iPhone’s list picker, a type step whose selector names a <select> on the page itself, with the option’s text (or its value) as value, chooses that option the way a person does: a tap on the list, a look at the options, a tap on the option’s row. The row says chose an option in <selector>.

{ "kind": "interact", "action": "type", "selector": "#country", "value": "Canada" }

A tap on the list or one of its options, a key pressed on it, a list that takes several choices (multiple) and a list inside an embedded frame are failed rows whose reason says why, and nothing is sent. So is any choice from a list on a session whose browser has no list picker. A row also fails, saying so, when the list has no option by that name, when its options did not open, when the list did not take the option, or when the page is drawn zoomed (nothing is tapped); a failed choice that left the select showing another option says so. Any step that taps, types, presses, swipes, scrolls or pauses fails, with nothing of it sent, when an option list an earlier choice left open could not be closed or checked (invalid_request, retryable: false: it is never sent again on its own); send it again, which looks at the list once more, or navigate to the page again. A navigate, back, wait, capture, extract or dialog step still runs.

How the option is matched. An option marked disabled is never chosen; the others are compared with the step’s value in two passes. First with each option’s value, exactly: case and spaces count. Only when none has that value, with each option’s text (what is written between <option> and </option>), ignoring case and any spaces at the start and end. The whole text must match; part of it never does. For <option value="CA">Canada (CA)</option>, send "CA" or "Canada (CA)", not "Canada". When several options match in the same pass, the first in the list is chosen. When nothing matches, the row fails and nothing is chosen. When the option that matches is already the one selected, nothing is tapped and the step succeeds.

A page can embed other documents in frames (an <iframe>: a payment form, a verification check, a chat window). Their contents are not part of the page itself, so a capture with dom_snapshot, or an extract with body: true, may carry a frame — the embedded document it read instead of the page, as a path of positions: its place among the page’s frames, then its place inside that frame for a nested one, outermost first ([0], [0, 1]). Positions are the browser’s own and follow the order it created the frames, which is not always the order of the <iframe> tags in the page’s markup: a frame a script inserted or moved is numbered when it was created, so [1] is not necessarily the second iframe tag. Absent, the step read the page itself; frame never appears on a screenshot or a PDF. On a session whose browser can read an element inside a frame, an extract by selector (and by attribute) may carry frame too: it read that element of the frame’s document, not the whole frame; on any other session such a step fails without running. A read with frame succeeds only when the session confirmed it read exactly that frame; a read that failed names the frame it asked for. frame also appears on an interact with type: it names the frame the step asked to type in, and the step’s selector is matched inside that frame, not the page. A type with frame succeeds only when the session confirmed it typed into exactly that frame. When the text was sent but the session did not confirm the frame, the step fails with diagnosis.category unknown, it is not tried again, and the turn ends on it: the text may be somewhere else on the page, so look at the page before typing again. A type without frame fails in the same way when the session reports it typed inside a frame. On a session whose device can act inside a frame, an interact with tap, and a wait with selector_visible, can carry frame too: the tap’s selector is matched, and the element is waited for, inside that frame. A scroll or a press never carries frame: both act on the page itself. Such a step succeeds only when the session confirmed it was done inside exactly that frame. When it did not, the step fails and is not tried again: a tap with diagnosis.category unknown, which ends the turn, and a wait with condition_not_met. A step without frame fails in the same way when the session reports it was done inside a frame. A tap inside a frame is made only when nothing on the page or in the frame covers its point; a covered one fails without tapping. After a tap or a type inside a frame, a key press that could submit a form (Enter) is judged on that frame’s form, and is not sent when that frame cannot be read. A frame drawn transformed (scaled, rotated or skewed) fails “it can’t be tapped here … — reading or waiting inside it still works”; a control off the screen inside a frame that may cover the screen fails “this frame may cover the screen, so the page could not be scrolled to the control inside it”; a frame the page could not scroll onto the screen fails “this frame is not fully on the screen and the page could not be scrolled to bring it there”, and a control the browser could not otherwise bring onto the screen inside its frame fails saying so. When the browser drags on a frame that covers the screen to bring the control into view and cannot, the step names what stopped it, and only that: a drag that did not bring the control any nearer to the screen; running out of drags or time; no safe point on the screen to start a drag from; a drag that could not be sent; a frame, control or page around it that could not be measured (this one also covers a page load or a new tab the browser could not rule out, so it ends “read the page before the next step, as the frame may have changed”); a control outside the visible area of the frame or a panel in it, or an area that could not be read; a page behind the frame that is locked or has too little room to scroll toward the control; or a frame whose content extends further that way, so a drag on it would move the page behind it. When a page load may have started while the browser places the tap — before any drag or during them — the step fails “a page load may have started while the tap inside this frame was being placed, so the control was not tapped … — read the page before the next step, as the frame may have changed”; that sentence never says a drag was made. A control inside a frame that the browser could not find again after it scrolled the page or dragged on the frame toward it fails “the control inside this frame could not be found again after the browser tried to scroll toward it, so nothing was typed or tapped inside it (the frame or page may have scrolled) — read the page before the next step”, with diagnosis.category element_not_found, and is not tried again. When the browser names no cause, or a cause this service does not know, the step fails “the browser could not act inside this frame here”. A control outside its frame’s visible area to the side that the frame could not be scrolled to fails saying so. Each of these is invalid_request, not retried, and nothing was typed or tapped inside the frame, but none of them says “nothing was done”: a drag on the frame, a scroll inside it or a page scroll the browser made before it stopped may have scrolled the frame or the page, and the sentence says so (“the frame or page may have scrolled”), so look at the page before the next step. A frame that is gone fails “is no longer on the page, so nothing was typed or tapped inside it (the frame or page may have scrolled) — look at the page or list the frames again … for its new path”: the browser can find it gone after a page scroll or a drag it already made. A tap inside a frame that pays, buys or deletes an account asks for your approval exactly as one on the page does. On any other session no other step can name a frame — a wait that names one fails without running, never as a wait on the page itself — so none can name one to tap into, though a tap_at step taps a point on the screen, and that point can be inside a frame; a tap_at never carries frame. The AI reads a frame, or acts inside one, only when the session has listed the page’s frames for it. After a type the session confirmed inside a frame, that frame, and no other, stays listed for the rest of the same plan, so clearing a field inside a frame and typing its new value run together — each later step there only once the browser reports the same frame (its address and name) at that place again; a frame the page replaced or moved meanwhile is not used, and that step fails without running. These are the rules for the AI’s own steps; steps you write yourself are checked against the browser’s own list of frames instead (see Steps inside an embedded frame).

A frame can also sit inside a shadow root (a web component’s own markup). Such a frame has no position among the page’s frames, so a level of its path is a string instead of a number: the selector of its iframe element in the document that holds it, with >>> stepping into a shadow root — ["checkout-form >>> iframe"], or [0, "x-pay >>> iframe"] for one inside the first frame. A string level is 1 to 1,024 characters, and a path is still at most 8 levels. On a session whose browser can enter such a frame, the AI is shown these frames when it looks at the page, and your own steps on POST /steps may name one by that selector path. GET /v1/agent-sessions/{id}/frames lists numbered frames only for now. A string level the browser’s own list of the page’s frames does not hold, or any string level on a session whose browser cannot enter such a frame, is not run (its row says why) and nothing is sent. A frame that is gone by the time the step runs fails with nothing typed or tapped inside it, saying it is no longer on the page (a scroll the browser made before it found the frame gone may have scrolled the frame or the page).

A session your program runs is in ai mode (the default): every message is planned and executed by the AI. Closed sessions return 409. The desktop app also runs manual and pair sessions, where a person drives the browser; the controls for those are not part of this API.

Running a task from code? Run AI tasks from your code walks through the whole flow — create a session, send the task, read the answer, approve or answer when the agent asks, stop, close — with complete TypeScript, Python, Go and curl programs.

Scope note. The agent-session writes that start or drive a session — create, reopen, message, steps, stop, close, resume, cookie import, history, file upload and the live-video token — gate on the broad write scope; there is no agent-sessions-specific granular scope. One documented write takes a granular scope: replacing the profile’s notes needs write:profiles, because it edits the profile and runs nothing. Regular session routes accept the granular write:sessions, but agent sessions do not have a granular equivalent. Reads — list, get, the transcript stream, page state, the network log, captures, cookies and downloads — need read:sessions, which broad read and account_owner also satisfy. If you mint a narrow CI key, include the broad write scope to call the write endpoints. An OAuth token can hold only granular scopes, so OAuth apps can’t run AI sessions today; use an API key for that.

Step results are the record

A turn that ran steps (kind: "plan-executed") lists every step that ran in results, in order. Each entry carries the step itself as intent — the selector it acted on, the value it typed, the address it opened — and what happened: a step that succeeded has a summary of what the session reported, and one that failed has a reason instead. Build your program on results: answer is text written for a person, while results is what was done on the page. A list of steps you send with POST /steps answers in the same shape.

A message:

{
  "user_message": "Sign in at https://shop.example.com/login as [email protected] with the password {{credential:cred_9f2c41ab7d6e4850bc13f07a5e9d2c88}}, open Orders and tell me the latest order number."
}

What came back:

{
  "kind": "plan-executed",
  "session": { ...AgentSession },
  "intents": [ ... ],
  "results": [
    {
      "kind": "success",
      "intent": { "kind": "navigate", "url": "https://shop.example.com/login" },
      "summary": "navigated to https://shop.example.com/login"
    },
    {
      "kind": "success",
      "intent": { "kind": "interact", "action": "type", "selector": "#email", "value": "[email protected]" },
      "summary": "typed into #email"
    },
    {
      "kind": "success",
      "intent": { "kind": "interact", "action": "type", "selector": "#password", "sensitive": true },
      "summary": "typed into #password"
    },
    {
      "kind": "success",
      "intent": { "kind": "interact", "action": "tap", "selector": "#sign-in" },
      "summary": "tapped #sign-in: the button “Sign in”"
    },
    {
      "kind": "success",
      "intent": { "kind": "interact", "action": "tap", "selector": "a[href=\"/orders\"]" },
      "summary": "tapped a[href=\"/orders\"]: the link “Orders”"
    }
  ],
  "ok": true,
  "answer": "Your latest order is A-10482, placed on 2 October 2026."
}

How to read it:

  • results[i].intent.selector is the element the step acted on, exactly as it was sent. Use it to run the step again yourself with POST /steps, or to name the element in your next message.
  • results[i].intent.value is the text a type step typed. A step that typed a saved credential or a session secret shows its placeholder ({{credential:cred_…}}), never the value. A secret is left out, and the step comes back with sensitive: true and no value (the third step above), when the step is marked sensitive: true, when its selector names a password, one-time-code or card field (#password, [autocomplete="one-time-code"], #cvv), or when it typed, outside an embedded frame, into a field the session had already read as secret on the page it was on. A password field with a plain name on a page the session has not read yet, or inside an embedded frame, is not recognised: mark such a step sensitive: true yourself.
  • results[i].summary, on a step that succeeded, is what the session reported, on one line. After a tap, it names the element the selector resolved to when the session can say (the button “Sign in”), so you can check the tap landed where you meant.
  • A failed step is kind: "failure" with a reason, and usually a diagnosis to branch on; a step held for your approval is kind: "confirmation_required" and did not run. Both are described under Message.
  • ok is false when the last planned steps had a failure, when a step is waiting for your approval, or when the turn did less than the message asked for (the full rule is under Message). A turn that recovered from a failed step can show ok: true with that failure still in results, so check each results[i].kind. ok: true does not by itself mean the task is finished: when it is not, the turn carries notice (a sentence) and notice_reason (one word to branch on), for example "notice_reason": "step_limit" — send “continue” to carry on.
  • intents is every step the turn attempted, including any it planned and never ran, so it can be longer than results. Read outcomes from results.

What stays the same between releases

  • intent — in intents and in each results[i].intent — is the replay contract. It has the AgentIntent shape of the OpenAPI document, its fields keep their meaning, and a step sent back to POST /steps exactly as it came is accepted and does the same thing. Build on it.
  • summary and reason on a step, and answer, answer_unavailable, notice and clarifying_question on a turn, are text for a person. Their wording can change between releases, without notice: show them, log them, but do not match on them. A program branches on kind, diagnosis.category, diagnosis.retryable, notice_reason, warning.kind and the typed fields (value, values, captureId, dialog), never on the words.

Turn an AI run into fixed steps

An AI turn tells you exactly what it did, so a task that works once can run again without the AI:

  1. Send the task as a message and check the turn: kind: "plan-executed", ok: true, and every results[i] a success.
  2. Take the steps from results[i].intent, in order (or intents, which also lists any step that was not run). A typed value that was stored as sensitive: true comes back without its value: put the value — or its {{credential:…}} placeholder — back in before you replay. So does the text typed into a prompt by a dialog step, which comes back as {{prompt text not stored}}.
  3. Send them to POST /v1/agent-sessions/{id}/steps on a session at the same starting page, at most eight per call. No model is called, and each step is done exactly as written.
  4. Read the new results the same way. A step that read the page (extract) returns its text in summary (or value / values), and that text is the page’s, so read it; do not compare it with the earlier run’s words.

What the AI did that is not a step is not replayed: it looked at the page (and at a screenshot) before each round of steps, and it waited out a page that was still loading. Add a wait step (idle, or selector_visible for the element you need next) where the page changes in between. A tap_at needs no screenshot in a list of steps (see tap_at above), so the AI’s point is sent as it was; a page whose layout changed puts something else under it, so a tap by selector is the sturdier replay where the page offers one.

Resource shape

{
  "id": "agt_<uuid>",
  "account_id": "<uuid>",
  "driftstack_session_id": "ses_<uuid> | null",
  "status": "provisioning | active | paused | closed",
  "closed_reason": "<string> | null",
  "closed_reason_detail": "<string> | null",
  "customer_close_reason": "<string> | null",
  "provisioning_detail": null,
  "ready": false,
  "ready_at": "<ISO-8601> | null",
  "closed_at": "<ISO-8601> | null",
  "token_budget_total": 100000,
  "token_budget_remaining": 99500,
  "transcript_length": 12,
  "history": {
    "entries_used": 12,
    "entries_max": 256,
    "entries_left": 244,
    "nearly_full": false,
    "entries_dropped": 0
  },
  "created_by_user_id": "<user-uuid> | null",
  "mode": "ai | manual | pair",
  "model": "claude-sonnet-5-5 | claude-haiku-5-5 | claude-fable-5-1 | claude-opus-5-5 | claude-opus-5 | claude-sonnet-5 | claude-opus-4-8 | claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5",
  "stop_on_exit_ip_change": false,
  "proxy_id": "<uuid> | null",
  "profile_id": "prof_<uuid> | null",
  "archetype": "iphone15_ios18_7_safari26_5 | null",
  "archetype_source": "explicit | profile | random | null",
  "resumed_from": "agt_<uuid> | null",
  "created_at": "<ISO-8601>",
  "updated_at": "<ISO-8601>",
  "livekit": {
    "ws_url": "wss://<livekit-host>",
    "room": "agt_<uuid>",
    "token": "<HS256 JWT>",
    "participant_identity": "customer-<account-uuid>",
    "expires_at": "<ISO-8601>"
  },
  "error_event": {
    "timestamp": "<ISO-8601>",
    "code": "launch_timeout",
    "severity": "info | warn | error | fatal",
    "summary": "The browser did not become ready in time.",
    "detail": "<string> | null",
    "customer_actionable": false,
    "retryable": true
  }
}

The status field reads active from the moment the session exists, before its browser has finished starting, so status alone does not tell you the browser is ready; ready below does. provisioning is kept in the list for a session whose setup step is reported in provisioning_detail; today a session reads active while it starts, VPN sessions included. If you do see provisioning, treat it as running but not ready: do not start work against the session, and do not treat it as finished. It is already consuming one of your concurrent-session slots, so a provisioning session counts against your cap exactly as an active one does.

ready turns true once the session’s browser has finished starting, and ready_at says when; status reads active before that. Wait for ready: true before sending the first message — a session on a VPN can take longer to become ready — and read a session that closes while ready is still false as one that failed to start, with closed_reason saying why. An older deployment does not send ready; read a missing ready as ready.

ready comes from the session’s own report, and some sessions cannot send that report again once it is lost (for example across a reconnect), so a running session can keep reading ready: false. A message is held for a session that is not ready only within 125 seconds of its creation, long enough for a VPN session to finish starting: if ready is still false after that while status stays active, send the message anyway — it is no longer held.

provisioning_detail names the step it is on (a snake_case token such as vpn_egress_bringing_up or vpn_egress_active), and is null once the session is active or closed. It may be absent entirely on older deployments, so read it defensively rather than assuming the key exists.

history says how much room the session’s live history has. The live history is what the agent works from: it holds at most 256 entries (about 256 steps calls, or roughly 85 to 127 AI messages) and 1 MiB. A steps call needs 1 free entry, a note in manual mode 1, and an AI message 3 (your message, the plan with its results, and the answer). When a call does not fit, the session does not close: the oldest entries are dropped from the live history and the session goes on. The drop keeps the newest entries that fit in half of each limit (128 entries and 512 KiB), from the start of a turn, and always keeps your first task and the newest two entries, so it happens about once every 125 steps calls, not on every call. Dropped entries are not deleted: the transcript stream still returns them under their own numbers, until the deployment’s retention empties a closed session’s transcript, dropped entries included. The agent no longer works from them, and a recipe built from the session and the profile’s activity leave them out too.

transcript_length counts every entry the session has written, dropped ones included, and never goes down; the newest entry is number transcript_length - 1. entries_used counts the entries in the live history, so it is the same number as transcript_length only until the first drop. entries_max is 256, and entries_left is the room before the next drop. entries_dropped says how many entries have been dropped so far (0 until the first drop); it is absent on older deployments, which close a full session instead. nearly_full turns true when fewer than 32 entries are left, or when the history is close to the 1 MiB limit, which a session with long entries can reach first with many entries still left. It means a drop is near; you need do nothing.

A session is still closed with closed_reason: "transcript-limit" in two cases: one call alone needs more room than the history has after a drop (a single call that adds several hundred KiB of text), or the session has already dropped the most it can (4,096 entries or 16 MiB). Then start a new session, with continue_from_agent_session_id to carry the recent conversation over. On the session of a message or steps answer the numbers already count that call’s own entries, and the same answer carries a warning while nearly_full is true. history is null on a closed session, in each row of List (read one session with GET /v1/agent-sessions/{id} to get it), and when the server cannot count the history; it is absent on older deployments. It never carries a size or any of the history itself.

closed_reason says why a closed session ended. It is an open string, not a fixed list: customer-closed (you closed it), budget-exhausted (its token budget ran out), transcript-limit (its history could not take a call; see history above), exit_ip_changed (see stop_on_exit_ip_change under Create), trial_session_limit (a free-trial session reached its 20 minutes), trial_time_used (the free trial’s 2 hours of session time ran out during the session), payment_failed (the account’s renewal payment didn’t go through, so its paid plan stopped), plan_ended (the account’s paid plan ended), browser_crashed (the session’s browser crashed — including when it reported that its network process crashed, after which Driftstack ends the session for you; see “When the browser’s network process crashes” under Message), and values such as idle_timeout or max_duration when the browser behind the session ended it. Read any other value as “the session could not start, or Driftstack ended it”. worker-disconnected means the Mac running the session went offline, and worker-restarted that it restarted (for example, to install an update); the session ended with it. Nothing in your requests caused either; start a new session. node_resource_pressure means the Mac running the session was briefly short of system resources on our side, so the session was stopped. Its error_event, when it has one, reports retryable: true. Nothing in your requests caused it; start a new session. session-errored means the session stopped on the Mac running it without a more specific reason: the browser reported an error, or the Mac stopped listing the session (it lost it) without saying how it ended, so Driftstack closed it rather than let it hold one of your concurrent-session slots. Nothing in your request caused it; start a new session. provisioning_interrupted means the Mac running the session picked it up and then stopped reporting it before it was ready, so Driftstack closed it rather than let it hold one of your concurrent-session slots. It closes with ready still false, and its error_event carries the same code, the summary “The session could not start on our side. Start it again.” and retryable: true. Nothing in your request caused it; start a new session. dispatch_no_live_node means the device the session was sent to went out of service just as it started, so the session closed moments after the create returned (rarely, it is already closed in the create response). Nothing in the request caused it; create the session again shortly with a new Idempotency-Key (the same key replays the closed session). When no device is free at the moment of the create, the create itself is refused with 503 and nothing is created — see No device free. archetype_config_failed means the device’s own configuration, on the Mac running the session, could not be loaded, either as the session started or later while it ran; launch_config_failed means that Mac could not set up the environment the device needs to start. Both are on our side, not in your request: the error_event carries the same code, customer_actionable: false, detail: null and the summary “This device’s configuration couldn’t be loaded on our side, so the session didn’t start. Choose a different device, or contact support if it keeps happening.” (or “…so this session was stopped. …” when it had been ready). retryable is false: until that Mac is fixed, a new session with the same device is likely to be sent to it and fail the same way, so choose a different device, or contact support if it keeps happening. upstream_proxy_not_public means the proxy saved for the session points at an address that is not on the public internet (a private or local one), so the Mac that would have run the session did not start it. Nothing ran. The error_event carries the same code, customer_actionable: true, detail: null and the summary “This session’s proxy address is not a public address, so the session was not started. Change the proxy and start again.” retryable is false: the same proxy is refused the same way each time, so change the proxy’s address first.

closed_reason_detail says why a closed session ended in plain words you can show to a person, and is null while the session has not ended. When the session’s error_event names the cause — an error or fatal event, or any event on a session that closed session-errored (for example because your proxy refused its sign-in) — it explains that cause rather than the close reason; a session you or Driftstack support stopped, or that reached a trial limit, says so whatever its error_event. Where there is something to do next, it ends with “Reopen the session to continue.” while the session can still be reopened (within 7 days of closed_at, for a session that ran through one of your proxies and whose profile, if its device came from one, still exists), and with “Start a new session to continue.” after that. A reason not in the table below gets “This session has ended. Contact support if you did not expect it and it keeps happening.” and the next step, never the raw value. The wording may change: branch on closed_reason and error_event.code, show closed_reason_detail. It is absent on older deployments.

The words for each reason (a closed_reason, or the error_event.code that explains it); “Next step: yes” means one of the two sentences above follows. transcript-limit and budget-exhausted are the exceptions: they end “To keep the recent conversation, start a new session that continues from this one.” (create it with continue_from_agent_session_id), and while the session can still be reopened they add “Reopening it starts a new session with the same setup and an empty history.” (budget-exhausted: “…with the same budget…”), because a reopen carries no conversation and keeps the budget:

Reason Words Next step
customer-closed You closed this session. no
session_ended This session ended normally. no
staff-stopped Driftstack support stopped this session. Contact support if you need to know why. no
idle_timeout This session was closed because nothing happened in it for longer than its idle limit. yes
max_duration This session reached the maximum running time set for it, so it was closed. yes
orphaned-lifetime This session reached the longest time a session may stay open, counted from when it started, so it was closed automatically. yes
trial_session_limit This trial session reached its time limit. You can start another while your trial has session time left, or choose a plan for longer sessions. no
trial_time_used Your trial’s session time ran out, so this session was closed. Choose a plan to keep running sessions. We keep your profiles, proxies and settings for 30 days after your trial ends. no
payment_failed This workspace’s last payment didn’t go through, so this session was stopped. The account owner can pay the open invoice or update the card; the plan comes back as soon as the payment goes through. no
plan_ended This session was closed because this workspace’s plan has ended. The account owner can choose a plan (or pay for a new crypto term) to start sessions again; profiles and settings are kept. no
budget-exhausted This session used up its token budget, so it was closed. Give the next session a larger budget if you need one. yes
transcript-limit This session’s history reached its maximum length, so it was closed. yes
exit_ip_changed Your proxy’s exit IP address changed while the session ran, and this session was set to stop when that happens. Check the proxy’s settings if its address should stay the same. yes
intent_deadline_exceeded A step ran past the time allowed for it, so the session was stopped. yes
session_resource_overuse The page used more memory or processing power than a session allows, so the session was stopped. yes
browser_crashed The browser running this session stopped unexpectedly. yes
browser_closed The browser running this session was closed. yes
browser_exited The browser running this session shut down. yes
renderer_crashed The page in this session stopped running and could not be recovered. yes
launch_timeout This session did not start in time. Nothing in your requests caused it. yes
render_failed The browser for this session did not start. Nothing in your requests caused it. yes
webkit_spawn_failed The browser for this session did not start. Nothing in your requests caused it. yes
webdriver_connect_failed The browser for this session did not start. Nothing in your requests caused it. yes
worker-restarted The Mac running this session restarted. yes
worker-disconnected The Mac running this session went offline. Nothing in your requests caused it. yes
node_drain The Mac running this session was taken out of service for maintenance. Nothing in your requests caused it. yes
node_shutdown The Mac running this session was taken out of service for maintenance. Nothing in your requests caused it. yes
node_shutting_down The Mac running this session was taken out of service for maintenance. Nothing in your requests caused it. yes
control_plane_unreachable We lost contact with the Mac running this session, so it was stopped. Nothing in your requests caused it. yes
session_not_found This session had already stopped on the Mac running it. yes
node_resource_pressure The Mac running this session was briefly overloaded on our side, so the session was stopped. Nothing in your requests caused it. yes
dispatch_no_live_node No Mac was free to start this session just then. Nothing in your requests caused it. Wait a minute or two first. yes
node_shedding No Mac was free to start this session just then. Nothing in your requests caused it. Wait a minute or two first. yes
session_refused_at_capacity No Mac was free to start this session just then. Nothing in your requests caused it. Wait a minute or two first. yes
dispatch_failed This session could not be started, so nothing ran in it. Nothing in your requests caused it. yes
create_failed This session could not be started, so nothing ran in it. Nothing in your requests caused it. yes
dispatch_owner_claim_failed This session could not be started, so nothing ran in it. Nothing in your requests caused it. yes
provisioning_interrupted This session stopped before it finished starting. Nothing in your requests caused it. yes
reaped_during_provisioning This session stopped before it finished starting. Nothing in your requests caused it. yes
screen_capture_denied Live view was not available for this session, so it was stopped before it started. Wait a few minutes first. yes
live_view_capture_unavailable The live view of this session could not start, so the session was stopped. Nothing in your requests caused it. yes
archetype_lookup_failed The device this session’s profile uses is on hold or not available right now. Pick another device for the profile. yes
archetype_not_supported The device profile this session asked for is not available right now. Pick another device for the profile. yes
session_config_invalid This session could not start with its current settings. Check the profile and proxy it uses. yes
archetype_config_failed This device’s configuration couldn’t be loaded on our side, so the session didn’t start. Choose a different device, or contact support if it keeps happening. no
launch_config_failed This device’s configuration couldn’t be loaded on our side, so the session didn’t start. Choose a different device, or contact support if it keeps happening. no
session-errored This session stopped unexpectedly because of a problem on our side. yes
unknown_error This session stopped unexpectedly because of a problem on our side. yes
proxy_required This session needs one of your saved proxies and none was given, so it was not started. Choose a proxy for it. yes
no_proxy_configured This session needs one of your saved proxies and none was given, so it was not started. Choose a proxy for it. yes
upstream_proxy_not_public This session’s proxy address is not a public address, so the session was not started. Change the proxy and start again. no
exit_timezone_unresolved This session was not started because we could not confirm where your proxy’s connection comes out. If it keeps happening, test the proxy. yes
proxy_connection_failed This session could not connect through your proxy. Check the proxy’s settings, or test it. yes
proxy_udp_unsupported Your proxy cannot carry the UDP traffic this session needs for HTTP/3, so it was not started. Use a proxy that supports UDP. yes
egress_unreachable We could not confirm where your proxy’s connection comes out, so the session was stopped. Check the proxy’s settings, or test it. yes
egress_invariant_violation Some of this session’s traffic did not go through your proxy, so the session was stopped to keep it on the proxy. Check the proxy’s settings, or test it. yes
egress_bind_failed The VPN for this session could not be started. Check the proxy’s VPN settings and that its server is up. yes
egress_lost The part of the connection we run for this session stopped while it was running. Nothing is wrong at your end. yes
proxy_boot_failed The part of the connection we run for this session did not start. Nothing is wrong at your end. yes
network_shim_boot_failed The part of the connection we run for this session did not start. Nothing is wrong at your end. yes
default_egress_unavailable The connection this older session ran on, which we provided, stopped working. That was ours to fix, not yours. yes
proxy_auth_failed Your proxy refused the username or password saved for it. Check them in the proxy’s settings. yes
egress_verification_unavailable We could not confirm that this session’s traffic was going through your proxy, so we stopped it rather than risk it going out unprotected. This one is ours, not your proxy. yes
vpn_bringup_failed The VPN for this session could not be brought up, and there was not enough detail to say why. Check the proxy’s VPN settings and that its server is up. yes
vpn_bringup_no_active_state The VPN for this session could not be brought up, and there was not enough detail to say why. Check the proxy’s VPN settings and that its server is up. yes
remote_unresolved The address your proxy points at could not be found, so nothing could connect. Check the address in the proxy’s settings, or ask whoever supplies it. yes
remote_refused Your proxy’s server refused the connection. Check the address and port in the proxy’s settings, or ask whoever supplies it. yes
remote_unreachable Nothing answered at your proxy’s address. Check the address and port in the proxy’s settings, or ask whoever supplies it. yes
remote_closed_during_setup Your proxy’s server closed the connection before the VPN was up. Check the proxy’s VPN settings, or ask whoever supplies it. yes
tls_handshake_failed The secure handshake with your proxy’s server did not complete. A certificate or key in the proxy’s settings may no longer match the server. yes
config_rejected The VPN configuration for this proxy was refused. Open the proxy and paste its configuration again. yes
auth_failed Your proxy refused the username or password saved for it. Check them in the proxy’s settings. yes
tunnel_setup_timeout The VPN for this session did not finish connecting in time. Check the proxy’s VPN settings and that its server is up. yes
no_output The VPN for this session could not be started on our side. This one is ours, not yours. yes
egress_bypasses_tunnel The VPN came up but did not carry all of this session’s traffic, so the session was stopped. Set the proxy to send all traffic through the VPN. yes
tunnel_no_egress The VPN came up but no traffic came back through it, so the session could not browse. Check that the VPN server forwards traffic. yes
vpn_no_dns Your VPN configuration sets no DNS server, so this session was not started. Add a DNS = line naming a resolver inside your VPN (WireGuard), or have the server push dhcp-option DNS (OpenVPN). We don’t use a public resolver for you. yes
exit_ip_check_unavailable We couldn’t check this session’s exit address, so it was stopped. The connection itself may be fine. yes
egress_ip_lookup_failed We couldn’t check the connection on our side, so this session was stopped. Start the session again. yes

stop_on_exit_ip_change is always present and echoes the create-time setting.

network_detail is on GET /v1/agent-sessions/{id} and on the create answer (never on a list row or another call’s answer): requested is true when the create sent network_detail: true, served says whether request detail is being recorded for the session, and reason is null when it is, otherwise not_requested, mode_off (the deployment is not recording request detail for this session) or not_recorded (asked, but the browser has sent no request detail yet).

proxy_id is the proxy the session’s traffic goes out through, one of your account’s proxies: the proxy_id you passed at create. It is set from the create’s response onwards, and routing new sessions somewhere else does not change it. Every agent session created since proxy_id became required has one; null appears only on an older session that was created without a proxy of yours. It is absent on older deployments, so read a missing key as “not reported”, not as null.

archetype is the device profile the session runs (an id from GET /v1/archetypes), fixed for the session’s lifetime, and archetype_source says where it came from: explicit (the create’s archetype), profile (the device of the profile it runs) or random (the create named neither, so the session runs as a fresh visitor on a random current iPhone your plan includes — see Create). Both are null on a session created before the server recorded them, and absent on older deployments.

resumed_from is the id of the ended session this one was reopened from, and null on a session that was created. It is only a link: the reopened session has the ended one’s configuration and none of its history. Absent on older deployments.

Two more fields are optional and read-only: liveness ({ state, fresh } — the browser’s latest reported state, active, provisioning, idle or terminating, and whether that report is recent) and capability_report (the browser’s latest report of what the session can do — for example its exit IP, country and timezone, and whether live video is streaming). Both are absent until the browser has reported; treat an absent field as “not known yet”, never as “not running”.

capability_report.egress_state says whether the session’s traffic is getting out: live; dead_proxy — your proxy stopped carrying traffic while the session ran, so check it before starting another; or default_connection_down — only on a session created before proxy_id became required, without a proxy of its own: the connection Driftstack provided for it then stopped carrying traffic. That one was on our side. No agent session created since reports it, because every one runs through one of your proxies. null means it has not been reported. Read a value you do not recognise as null.

capability_report.os_fingerprint is the last OS reading Driftstack took of the session’s proxy — the same reading as the proxy’s own os_fingerprint (see Proxies): os, confidence, at (when it was taken; it can be any age), and how it was taken — observed_via, single_host_vantage and web_port_vantage, with direct_reading and website_like_reading as the customer names of the last two. Read a missing or false value on either pair as “this reading does not describe the path a website sees”. null means not measured, never “no OS”.

capability_report.exit_tcp_stack says whether the network stack of the session’s proxy looks like an iPhone’s. Your proxy opens its own connection to every site, so the first settings of that connection come from the proxy’s own operating system, not from the Mac running the session, and a site that checks them can notice when they differ. It is measured once, when the session starts: verdict is match, mismatch or unknown (it could not be decided), measured_at is when Driftstack took the reading, and proxy_id is the proxy it describes. A mismatch is a warning: nothing is blocked and the session keeps running. null means it was not measured: the check is off, the session has no proxy of its own, the reading could not be timed, the Mac running the session was too busy to run the check, or the session has moved to another proxy since the reading. Read a verdict you do not recognise as unknown.

upload_max_file_bytes is the largest file, in bytes, that one upload to this session can carry right now. Each session takes a file up to its own size, so this is often smaller than the 64 MiB per-file maximum, and it can change while the session runs. It is returned by GET /v1/agent-sessions/{id} while the session is running and has reported it, and absent otherwise — absent means “not known”, not “no limit”. Check a file against it before reading or encoding the file.

The error_event field is optional and nullable — it carries the most recent launch or runtime failure recorded for the session, and is absent or null when none has been reported. Branch on its two booleans rather than on the prose: customer_actionable says whether a human can do anything about the failure, and retryable says whether repeating the same call is worth trying. detail is null when the server has nothing to add beyond summary, and severity is one of info, warn, error, fatal. An error_event does not by itself close the session — read status for that.

Two codes concern a session started without a proxy of your own. default_egress_unavailable appears only on a session created before proxy_id became required, without one: the connection Driftstack provided for it then failed; customer_actionable is false, because it was ours to fix. The same value can appear as closed_reason. proxy_required means the machine running the session refused a session that reached it with no proxy; sessions run only through a proxy you have added, so start it again with one. (A create without proxy_id never gets that far — it is refused with 422 proxy-required; see Create.)

session_not_found means a request reached the Mac running the session after the session had already stopped there. Its summary is “This session had already stopped on the Mac running it, so the last request sent to it could not be carried out.” and detail is null. It is not recorded on a session that has already closed for a reason of its own, so it does not hide why that session stopped.

archetype_lookup_failed means the session could not start on its profile’s device: that device is on hold or still in development (the create refuses these up front with 409 device-unavailable, so this appears only when the catalog changed in between), or it cannot run right now for another reason. Its summary says so in words you can show — for a device on hold, the hold sentence with its reason in plain words, such as “This device profile is on hold while we confirm it matches a real iPhone. Pick another device.”; otherwise “This profile’s device isn’t available right now.” — and detail is null. It is fatal, customer_actionable and not retryable: the same profile fails the same way. The session closes with it; start a new one on a profile with another device.

The livekit field is optional — auto-populated on the session-create response when live video is available on the deployment, and absent otherwise. It is also absent when the session in the response is already closed, since nothing will ever stream into it. Clients that need a token for a running session in the absent case use the explicit endpoint at Live video (LiveKit) below.

ID-format note. The agent-sessions resource emits account_id as a bare UUID (no acc_ prefix), unlike GET /v1/whoami, which emits acc_<uuid>, and the GET /v1/sessions/:id resource which emits prefixed ses_/acc_/ key_ IDs. Customer code comparing agentSession.account_id against whoami.account_id must strip the acc_ prefix from the latter first. (id and driftstack_session_id are prefixed — agt_<uuid> and ses_<uuid>. Only account_id is bare here.)

Create

POST /v1/agent-sessions

Request body (proxy_id is required; every other field is optional):

{
  "mode": "ai | manual | pair",
  "model": "claude-sonnet-5-5 | claude-haiku-5-5 | claude-fable-5-1 | claude-opus-5-5 | claude-opus-5 | claude-sonnet-5 | claude-opus-4-8 | claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5",
  "driftstack_session_id": "ses_<uuid>",
  "token_budget": 100000,
  "archetype": "iphone15_ios18_7_safari26_5",
  "profile_id": "prof_<uuid>",
  "proxy_id": "a1b2c3d4-...",
  "initial_url": "https://driftstack.io",
  "geolocation": { "latitude": 48.8566, "longitude": 2.3522, "accuracy": 20 },
  "continue_from_agent_session_id": "agt_<uuid>",
  "stop_on_exit_ip_change": false,
  "skip_proxy_probe": false,
  "network_detail": false
}

Headers:

  • Idempotency-Key: <string> (optional) — retries with the same key replay the original 201 instead of minting a duplicate row. This endpoint is one of the four that honour the header; see Idempotency keys for the full list and the endpoints that ignore it.
  • x-byok-anthropic-api-key: sk-ant-... (optional) — your own Anthropic key. At create it only decides whether an Opus or Fable model may be chosen without a stored key (see below); the key is not stored, so send it on every message too.

Response 201 Created returns the resource above.

Plan availability. AI-driven sessions (mode: "ai", the default when mode is omitted) require a plan with the AI agent: every PAID plan on offer, and the earlier Team, Agency and API plans. On the free trial, and on the earlier Free and Personal plans, the create is refused with a 403 forbidden tier error — on the trial the detail reads “AI isn’t included on this account’s current plan (Trial).” and names the plans that do include the agent. mode: "manual" is not gated and works on every plan, the trial included. Team-scoped creates (X-Driftstack-Account) gate on the owner account’s tier — the account the session runs and bills against.

Session time on the free trial. Every trial session — manual or code-driven, from the desktop app, the API or the SDKs — runs for at most 20 minutes, and the trial includes 2 hours of session time in total, counted from the day it starts. A new session runs for 20 minutes or for what is left of the 2 hours, whichever is shorter: the session is told the limit when it starts, and Driftstack also closes a session that is still running within about two minutes of the mark (closed_reason: "trial_session_limit", or "trial_time_used" when the 2 hours ran out). Once none is left, the create is refused with 429 (tier-limit, code: "trial_session_time_used"). Paid plans have no limit of 20 minutes a session and no total of session hours. A paid session still ends on its own: after a time without activity, at 4 hours if it was created in manual mode, and in every case about 12 hours after it was created (see Turn and session limits).

If mode is omitted the server defaults to ai. If model is omitted it defaults to claude-sonnet-5-5 (every earlier id stays accepted for back-compat, and a session keeps the model it was created with) — the model selects which Claude model the AI agent runs, and applies in ai and pair mode. token_budget is the most model tokens the session may use (see Sizing the token budget). The browser starts when the session is created (ready turns true once it has started). The optional driftstack_session_id links an existing browser session you created with /v1/sessions, for reference; it does not change which browser the AI drives.

A shorter idle timeout

A create that does not send idle_timeout_seconds gets the default idle timeout for its mode, and "idle_timeout_seconds": null is the same as leaving it out.

idle_timeout_seconds (a whole number of seconds) closes the session (closed_reason: "idle_timeout") after that long with nothing reaching its browser, instead of the default for its mode: about 5 minutes for ai and pair, 30 minutes for manual. It can only shorten that default: the value must be from 120 up to the mode’s default (300 for ai and pair, 1800 for manual); any other value is 400 validation-failed naming idle_timeout_seconds. The session reads it back as idle_timeout_seconds (absent on a session that uses the default), and a reopen keeps it while it is still within that range for the session’s mode (a session moved from manual to ai with a value over 300 reopens with the ai default). A retry with the same Idempotency-Key returns the first session with its first value, even if the retry sends a different value in the range. The value is checked before the key is looked up, so a retry whose value is outside the range is the 400 above; the first session is not affected by it.

What counts as activity is the same with or without the field:

  • Counts: a step being sent to the browser, and that step ending — each step of POST /v1/agent-sessions/{id}/steps and each step an AI turn runs; a paused session resuming; and live-view or manual input (a touch, a key, a URL typed in the address bar; at most once a second, and keep-alive pings do not count).
  • Does not count: reading the session — GET /v1/agent-sessions/{id}, the list, page-state, the network log, the transcript stream — because those are answered without reaching the browser; and an AI turn’s planning between two steps.
  • Never closed as idle: a session still starting, a paused one, and one running a step. A paused session’s clock starts again when it resumes.
  • The clock starts when the session is created, not when it is ready. A start can take up to about 90 seconds (a VPN proxy is the slow case), so send the first step soon after ready turns true. For an ai session through a VPN proxy, choose 180 or more.

Sizing the token budget

token_budget is set per session, when you create it, and can be at most 10,000,000 tokens. If you leave it out, the session gets:

  • 500,000 tokens when it runs on your own Anthropic key: stored in the dashboard, or sent in the x-byok-anthropic-api-key header on the create. That header is not stored, so send it on every message too;
  • 100,000 tokens when it runs on Driftstack’s included AI.

A value you send always wins over the default.

To size it, count about 25,000 tokens per turn on a complex page (a long form, a page with many controls). A 10-page form wizard therefore needs roughly 750,000: send "token_budget": 750000. What a turn actually took from the budget is its usage.turn_budget_debited_tokens (see Message for usage); usage.anthropic_input_tokens is the size of one call’s prompt, not the turn’s cost.

If a long job needs more, add tokens to the running session with POST /v1/agent-sessions/{id}/budget before it runs out; the session carries on where it is. When a turn stops with notice_reason: "budget_low", top up first, then send “continue”: a message sent before the top-up is not run — it is refused (the session stays open, and nothing is charged) with how many tokens to add, and that refusal carries notice_reason: "budget_low". The agent does not start planning a message with less than 6,000 tokens left, and the number it asks for also covers what that turn’s first planning call can use, so adding that much lets the next message run. A turn that starts with only a little more than it needs can still use the last token and close the session. A session created with a token_budget under 6,000 can never plan a message: give it at least that much (the default is 500,000 on your own model key, 100,000 on the included model).

When a turn spends the very last token, the session closes (closed_reason: "budget-exhausted") and its page, cookies and transcript go with it. The way back is to reopen it within 7 days: the new session starts with the ended one’s total, top-ups included (but not its page, cookies or transcript).

Claude Opus models (claude-opus-5-5, claude-opus-5, claude-opus-4-8, claude-opus-4-7) and Claude Fable 5.1 (claude-fable-5-1) are available with your own Anthropic key only. On an account that would run the session on bundled billing — no key of its own, sent on this request or stored — creating an ai or pair session with one of these models returns 403 with requires_own_key: true and the refused model. The same check runs on every message, because a session moves to bundled billing if its key is removed or expires.

Which iPhone the session is. A session created without a profile or archetype runs as a fresh visitor on a random current iPhone; pass archetype to pin one, or profile_id to keep an identity between runs. The random device is drawn from among the devices your plan includes that are open for new profiles (see GET /v1/archetypes), weighted toward the newest iOS the way real traffic skews; it is chosen once, when the session is created, and never changes while the session runs. Sessions created without a device used to all share one default, which made many fresh sessions from one account look alike; now each is its own visitor. The response says what you got: archetype names the device and archetype_source is random, explicit (you passed archetype) or profile (the profile’s device; an explicit archetype wins over the profile’s). An archetype that is not on the list is a 400; one your plan does not include is a 403 (code: "device_not_on_plan").

The optional profile_id attaches one of your saved profiles (a persistent browser identity — cookies, localStorage, etc.) to the session, so the run resumes that profile’s stored state and saves changes back when it ends. Pass the prof_<uuid> id from the profiles API (a bare uuid is also accepted). It must reference a profile your account owns; an unknown or not-owned id returns 404. Omit it for a stateless (fresh) session.

A profile can have only one live session at a time. If the profile already has a non-terminal session, the create is refused with 409 profile-in-use (the body’s active_session_id names the live session). This prevents two sessions on the same profile from overwriting each other’s saved cookies and logins. End the named session if you created it — or wait for it to finish — before launching another. Sessions without a profile_id are never affected.

A profile whose device is not offered right now — on hold until the evidence behind it exists, or still in development (the device picker shows it but does not offer it, and GET /v1/archetypes does not list it) — cannot start a session. The create is refused with 409 device-unavailable before any session exists, so nothing starts and no concurrent-session slot is used. The body’s detail is a sentence you can show, such as “This device profile is on hold while we check it against a real iPhone. Pick another device.”; its archetype is the profile’s device id, and held_reason is the reason for the hold in one plain sentence, such as “We’re still checking it against a real iPhone.”, when there is one. A retry with the same Idempotency-Key is refused the same way; a create that already succeeded under that key still replays its 201. Launch a profile on a device from GET /v1/archetypes instead.

The required proxy_id is the saved account proxy the session’s traffic goes out through; pass the bare proxy uuid. Every agent session runs through a proxy you chose — Driftstack has no connection of its own to run it on. To find the id, see Find a proxy’s id: GET /v1/account/me/proxies lists them with a key that has the account_owner scope. It must reference a proxy your account owns — an unknown or not-owned id returns 404 with resource: "proxy". In a team workspace (X-Driftstack-Account), an admin may use a proxy the owner saved or one saved on the admin’s own account — where the desktop app saves every proxy it uses; the session is the owner’s, the proxy stays the admin’s, and the owner cannot list it or start a session through it. The session’s created_by_user_id then names that admin. A create without it (or with proxy_id set to null or "") is refused with 422 proxy-required (code: "proxy_required", and a detail naming the proxy_id field) before anything else happens: no session is created, nothing is recorded under your Idempotency-Key (a retry is refused the same way, and the same key works once you add a proxy_id), and no session limit or rate-limit allowance beyond the request itself is used. The one exception is a retry of a create that succeeded before proxy_id became required: under the same Idempotency-Key it returns that create’s original response, as every retry of a successful create does.

Every session needs a proxy, so drive pages here. Hosted Driftstack runs every session through a proxy of yours — sessions created with POST /v1/sessions too — and the step-by-step operations of /v1/sessions (navigate, interact, wait and the rest) answer 503 driver-not-integrated on a session that runs through a proxy. So drive pages through an agent session: POST /message for an AI turn, POST /steps for steps you write yourself.

The optional initial_url sets the start URL the remote browser opens on launch, overriding the default start URL. It must be an absolute http(s) URL; file:, javascript:, and data: schemes are rejected (400). Omit it to use the default. For a session that runs a saved profile, initial_url, when given, is where the session starts; the profile’s last active tab is used only when no initial_url is passed.

The first message to a session created with initial_url can arrive while that page is still loading. Until 12 seconds after the session became ready (ready_at), the agent does not read inside the page’s embedded frames, or plan from a page that shows little yet — no heading and fewer than 200 characters of text outside its navigation, header and footer, which a short sign-in form that has fully loaded can also be: it waits out the rest of those 12 seconds without touching the page, then reads it again. So on that first message a page with frames, or one with that little on it, can take up to that long to be read. Within those 12 seconds, a wait for the page to load that you ask for on that page is judged by what the page shows; after them it is the usual wait. A session created without initial_url, and any later page, is not affected.

The optional geolocation explicitly overrides the location reported by the session’s navigator.geolocation. By default you should not set this — when omitted, the device’s location is derived from the proxy exit IP, so the reported location is automatically coherent with the session’s apparent network location. Supply explicit coordinates only when you know the proxy’s true physical location better than IP geolocation does; coordinates that diverge from the proxy’s exit country make the session’s fingerprint internally inconsistent (a detectable signal). latitude is -90..90, longitude is -180..180, and the optional accuracy is in meters (omit for the device default). Out-of-range values are rejected (400).

The optional continue_from_agent_session_id is the id of one of your closed agent sessions. The new session starts with that session’s recent conversation, so the agent remembers it — use it to carry on after a session closed with budget-exhausted or transcript-limit. A history that fits in half of the session history limit (128 entries and 512 KiB) carries over whole. A longer one carries your first message and its most recent part, up to that half: the entries between them are left out, so the new session has room to keep going. What it carries is what Driftstack stores: your messages and the AI’s replies (with card numbers, saved credentials and the passwords and codes of four or more characters a step typed into a recognised password or code field, and secret parameters in web addresses, masked) and each step with its site, the step keeping no typed value and no page text (a step held for approval keeps the words of the control it would press). A closed session’s transcript can be emptied by the deployment’s retention, and a session continued from an emptied one carries nothing. An unknown id, or one your account does not own, returns 404; a session that is not closed yet returns 409 conflict.

Set stop_on_exit_ip_change: true to end the session (closed_reason: "exit_ip_changed") if its proxy’s exit IP changes while it runs, so a proxy that silently rotates cannot carry on from a new apparent location. It defaults to false.

Set network_detail: true to ask for this session’s network request detail. It defaults to false. Where the deployment does not offer request detail, true is refused with 400 bad-request (code: "network_detail_not_offered") and nothing is created; it is never silently ignored.

The create normally tests the proxy you named live first and refuses with 422 proxy-validation-failed (its reason says why — see Account proxies) if it does not work, before any session exists. If the test cannot tell either way — nothing answered Driftstack’s servers at all, or the test failed on our side — the create is not refused, because the session connects from a different network than the test (see When the check cannot tell). skip_proxy_probe: true skips that test for this launch only.

No device free

Driftstack picks the machine for each new session, preferring one with room for it and, when your proxy’s exit country is known, one near that exit that has room. There is nothing to configure, and it is not a promise about where a session runs.

When none of the machines that run sessions can take a new one — none is connected, each one is in maintenance, or none can run the session’s device profile — the create is refused with 503 feature-unavailable (code: "dispatch_no_live_node", retryable: true, retry_after_seconds) and a Retry-After header, and nothing is created: no session, no session time counted on your plan, and nothing stored under your Idempotency-Key. Wait the Retry-After seconds and send the same request again; the same key is fine, and starts the session once a device is free. A create that would be refused for another reason — your plan, an ended trial, used-up trial time, storage, or your concurrent-session limit — gets that refusal instead, since waiting for a device would not change it. POST /v1/sessions answers the same way.

Sent again while the first one starts. A create sent again with the same Idempotency-Key while the first one is still placing the session’s browser on its Mac waits for that, up to about 20 seconds, and then answers the session with its livekit live view, for the Mac that runs it. If the session is still starting after that, the answer is 503 feature-unavailable (code: "session_starting", retryable: true) with a Retry-After of 5 seconds. That session exists, so send the same request again with the same key; it answers the session once it is placed. A session whose first create stopped before placing it is closed instead, and the answer is that closed session.

When the only reason is that the machines are being updated (each connected one is in maintenance), the create does not refuse at once: it waits up to 25 seconds for one to come back, and starts the session as normal if one does. If none does, the answer is the same 503 with node_updating: true, a Retry-After of 60 seconds and the detail “The device is being updated…” — and nothing is created, so the same request with the same key is fine. Closing the connection while the create waits ends the wait; nothing is created. POST /v1/sessions and POST /v1/profiles/{id}/launch wait the same way for a session started through a proxy.

A machine can also be booked for maintenance. When every machine that could run the session is in such a window, the create is refused with 503 maintenance (code: "maintenance", retryable: true) and a Retry-After header, and nothing is created: no session, no session time counted on your plan, and nothing stored under your Idempotency-Key. until is when the window ends (ISO 8601, UTC) and retry_after_seconds the seconds until then, the same as Retry-After; the detail says the time, for example “This machine is in maintenance until 14:30 UTC; please try again then.” Send the same request again after until; the same key is fine. Sessions already running are not affected. POST /v1/sessions and POST /v1/profiles/{id}/launch answer the same way.

Launching through the profile’s proxy

A create that names a profile_id and no proxy_id uses the proxy the profile is bound to (default_proxy_id), so the same profile leaves through the same exit on every computer. A detached profile — its proxy was deleted — is refused with 409 (resource: "profile", code: "proxy_detached", This profile’s proxy is no longer available. Choose a proxy.) and nothing is created: it is never given another proxy. An unset profile, or no profile, with no proxy_id is the 422 proxy-required above. An explicit proxy_id always wins over the profile’s. The profile’s geolocation and stop_on_exit_ip_change default onto the session when the body omits them.

Every session carries profile_id beside proxy_id, so a second computer can show a profile as running. Creating a session stamps the profile’s and the proxy’s last_used_at.

Over your plan. After a downgrade, or on the trial (which keeps one saved profile), an account may hold more saved profiles or proxies than its plan allows. Nothing is deleted: the most recently used, up to the plan’s number, keep launching — on the trial, only the most recently used profile; the rest are refused with 429 (tier-limit, code: "over_plan_limit", resource: "profile" or "proxy") until you delete some or upgrade. The same rule applies to POST /v1/sessions with a profile_id and to POST /v1/profiles/:id/launch.

Create errors

Status Type When
400 validation-failed a field fails validation (for example a non-http(s) initial_url or an out-of-range geolocation)
400 bad-request proxy_id names an HTTP proxy; browser sessions need a SOCKS5, OpenVPN or WireGuard proxy
400 bad-request network_detail: true where the deployment does not offer request detail (code: "network_detail_not_offered"); nothing was created
403 forbidden ai or pair on a plan without the AI agent; an Opus or Fable model without your own key (requires_own_key: true, model); a team member without the admin role (required_role: "admin"); a VPN proxy your plan does not include (feature: "vpnEgress"); an archetype, or a profile_id’s device, your plan does not include (code: "device_not_on_plan")
404 not-found profile_id, proxy_id (resource: "proxy"), driftstack_session_id or continue_from_agent_session_id is unknown or not yours
409 profile-in-use the profile already has a live session (carries active_session_id)
409 device-unavailable the profile’s device is not offered right now — on hold or still in development (carries archetype, and held_reason when there is one); nothing was started
409 conflict continue_from_agent_session_id names a session that is not closed yet
409 storage-quota-exceeded your plan’s profile storage is full (profile-backed creates only)
422 proxy-validation-failed the live proxy test failed (carries reason)
422 proxy-required the body names no proxy_id; nothing was created (carries code: "proxy_required")
429 concurrency-limit your account already has as many open agent sessions as your plan allows (carries current_sessions, limit, and open_session_ids — see below); a provisioning session counts
429 tier-limit the free trial’s 2 hours of session time are used up (code: "trial_session_time_used"); or profile_id / proxy_id is outside the ones your plan can launch (code: "over_plan_limit")
503 feature-unavailable no machine could take a new session just then (code: "dispatch_no_live_node", retryable: true, with Retry-After); nothing was created, so send the same request again after the wait, with the same Idempotency-Key if you sent one — see No device free
503 maintenance every machine that could run the session is in a booked maintenance window (code: "maintenance", retryable: true, with until, retry_after_seconds and Retry-After); nothing was created, so send the same request again after until, with the same Idempotency-Key if you sent one — see No device free

An open agent session counts against your plan’s limit until it is closed, whether or not a turn is running: until you close it (DELETE /v1/agent-sessions/{id}) or it ends on its own (after a time without activity, at its time limit, when its budget runs out, or when the machine running it loses it). A session that has ended is closed and its slot is free at once: no DELETE is needed. So on a plan whose limit is 1, a session an earlier run left open makes the next create answer 429 concurrency-limit (current_sessions and limit in the body, no Retry-After), and nothing is created. It is not a fault: list your open sessions with GET /v1/agent-sessions?status=open, close the ones you created and no longer need, and create again. Never close a session you did not create: a person may be using it. A session started with POST /v1/sessions through a proxy is listed here too, as a manual session, and holds one of these slots until it is destroyed. Close sessions in a finally block so a run that fails part-way does not leave one open.

The 429 body also names the sessions holding the slots:

{
  "type": "https://errors.driftstack.dev/concurrency-limit",
  "title": "Concurrent session limit reached",
  "status": 429,
  "detail": "Account already has 1 active sessions; tier permits 1.",
  "current_sessions": 1,
  "limit": 1,
  "open_session_ids": ["agt_3f1c9e2a-6d4b-4c1e-9a7f-2b8d5e0c4a11"],
  "open_session_ids_truncated": false
}
  • open_session_ids — the ids of the sessions the limit counts (status provisioning or active), newest first, at most 100. They are sessions of the account the create is for: yours, or the team owner’s when you create on a team as its admin. Ids only, nothing else about them. They are read just after the refusal, so one may have closed, or another opened, since.
  • open_session_ids_truncated — true when there were more than 100; page GET /v1/agent-sessions?status=open for the rest. A session paused by a bot check still reads active and holds a slot; no session reads paused today.
  • Both are absent when the ids could not be read, and when the key that sent the create cannot list sessions (the list needs read:sessions, broad read or account_owner; a create needs only write). The 429 is the same. A key that can list sessions finds them with ?status=open; a key that cannot needs one that can. Absent never means “no session is open”.
  • A reopen refused at the limit carries the same two fields, read the same way, for the account the reopened session would belong to.
  • current_sessions is your plan’s limit, not a count: it reads the same as limit. If your limit was lowered while more sessions were open, open_session_ids can list more ids than current_sessions says.

A client whose earlier process died can close the session it left behind with DELETE /v1/agent-sessions/{id}, then create again. Close only an id your program created (keep the ids you create): the list can hold a session a person is using or one another program started. The SDKs read both fields on ConcurrencyLimitError from their next releases: openSessionIds / openSessionIdsTruncated from TypeScript 0.6.0, open_session_ids / open_session_ids_truncated from Python 0.6.0, and OpenSessionIDs / OpenSessionIDsTruncated from Go 0.8.0. Until you are on one of those, read them from the error’s raw problem document: err.extensions.open_session_ids (TypeScript), err.problem.get("open_session_ids") (Python) or err.Problem["open_session_ids"] (Go).

List

GET /v1/agent-sessions

Your agent sessions, newest first, cursor-paginated. Returns the standard envelope: { data, has_more, next_cursor }. limit defaults to 50 and caps at 100 — that is the page size, not a ceiling on what you can reach; pass the prior page’s next_cursor to continue. All three SDKs wrap this as list(), with iterate() to walk every page.

Filter by status. ?status= lists only the sessions with that status:

status Lists the sessions whose status reads
provisioning provisioning: still starting, with a start-up step in provisioning_detail
active active: running, or still starting with no step reported (ready: false; see Resource shape)
paused paused (no session reads this today: a session paused by a bot check reads active)
closed closed: ended
open anything but closed: provisioning, active or paused

Without status the list is every session, as before. One value per request. Any other value — an unknown word, an empty status=, or status sent twice — is refused with 400 validation-failed, and issues.fieldErrors.status names the field; nothing is listed. So is status under another spelling of the key (status[]=open, status[0]=open, Status=open): send the plain key status, once. Keep the same status when you pass next_cursor: the cursor works the same with a filter. A session’s status can change while you page, so a session that ended between two pages may be missing from an open walk or appear in a later closed one.

curl -s "https://api.driftstack.dev/v1/agent-sessions?status=open" \
  -H "Authorization: Bearer $DRIFTSTACK_API_KEY"

The SDKs take it from their next releases: list({ status: 'open' }) from TypeScript 0.6.0, list(status="open") from Python 0.6.0 and List(ctx, &ListAgentSessionsQuery{Status: "open"}) from Go 0.8.0; iterate() takes it too and keeps it on every page. Earlier versions do not send it (TypeScript 0.5.0 ignores a status it does not know and lists every session), so until you upgrade, call GET /v1/agent-sessions?status=open directly, as in the curl above.

Requires read:sessions, broad read, or account_owner.

Team members need the admin role here, unlike the plain session list. An agent session carries the model transcript and live control state, so the collection is not widened to read-only members — a member acting on an owner gets 403. See Team RBAC.

Get

GET /v1/agent-sessions/{id}

Returns the resource above. Cross-account lookups return 404 (no existence disclosure).

Message

POST /v1/agent-sessions/{id}/message

Run one AI turn — plan and execute — (or, in manual mode, log the message and return without executing).

The message is kept word for word in the session’s history and is sent to the model. Decide how to pass a sensitive value before you write it: never put a password, a one-time code or a card number in user_message. Register it for this session as a session secret, or save a password or a one-time code once as a saved credential (a card number can only be a session secret), and write its placeholder ({{credential:cred_…}} or {{credential:sec_…}}) in the message. Driftstack puts the value into the step only as it is sent to the browser. A session secret’s value is never stored; a saved credential’s value is never returned, logged or put in a step’s result.

Request body:

{
  "user_message": "open https://example.com and capture a screenshot",
  "approve_consequential_actions": [{ "category": "purchase", "matched_text": "Buy now" }]
}
  • user_message (required) — the task, or your reply to the agent, 1 to 8,000 characters. It is kept word for word in the session’s history and is sent to the model, so never put a password, a one-time code or a card number in it (see above).
  • approve_consequential_actions (optional) — up to 20 approvals, each { category, matched_text } with matched_text 1 to 200 characters, for a step the previous turn held for your approval. See Confirming a purchase, payment or account deletion. The field must be spelled exactly so: a body with approvals, approval, approve, approve_actions, approved_actions, approve_consequential_action, approveConsequentialActions or approve_consequential_actions_list is refused with 400 (validation-failed), whose issues name the key: unknown field 'approvals'; did you mean approve_consequential_actions?. Any other unknown key is ignored, as before, and listed in the x-driftstack-unknown-fields response header.

These are the only two fields. There is no option that shapes the reply — no structured-output or JSON mode. Asking for JSON in user_message (“reply with only JSON”) changes neither the shape of the response nor the type of answer, which is always a string; see answer below.

Headers:

  • Idempotency-Key: <UUID> (strongly recommended) — identifies this logical turn. If the heartbeat stream or final response is lost, retry the exact same session/message/approval request with the same key; the server replays the recorded final status and body without running the browser task again. Changing the message, session, or approvals requires a new key. The BYOK header is deliberately outside receipt identity: reusing a key after changing that credential still replays the original terminal result and never executes a second browser turn. Use a new idempotency key for an intentionally new AI turn. A manual transcript turn never reads or hashes the irrelevant BYOK header. Reuse while the original outcome is still unknown returns 409 and does not start another turn. In rare cases the server could not keep the response (it could not be checked for secrets in time): a retry with the same key then answers 409 with code: "response_not_kept", and the original request is not run again — check the session’s transcript for its result.
  • x-byok-anthropic-api-key: sk-ant-... (optional) — supply a per-request BYOK key that overrides any account-stored key for this turn. Useful for users who don’t want to persist a key but do want each request authenticated against their own Anthropic account. Never logged.

Each message runs in the mode the session was in when it arrived. A manual message is transcript-only and never touches BYOK, bundled-LLM, or the browser. An ai message (or a pair message while AI is driving) keeps that mode for the whole turn; a takeover, handback, mode change, pause, or close cancels the turn even if the session later returns to the same mode.

A takeover, handback, mode change or close stops the AI call the turn has in flight rather than letting it finish unread, so the answer comes back as soon as that call stops. A takeover, handback or mode change answers this message with the 409 below; a close ends the turn as kind: "stopped" — see Close. A pause is the one that does not: nothing further is started, but a call already in flight finishes before the turn answers.

If control changes while a turn is still running, the request returns 409 conflict with ai_control_unavailable: true and a phase, and no further work is started. Work already completed is still reported: consumed model usage can include tokens_consumed and usage, and settled browser steps can appear as redacted partial_results. Do not replay those partial steps automatically; inspect the current session under its new controller first.

Response (200) is a discriminated union by kind:

// "plan-executed"
{
  "kind": "plan-executed",
  "session": { ...AgentSession },
  "intents": [ { "kind": "navigate", "url": "https://example.com" } ],
  "results": [
    { "kind": "success", "intent": { ... }, "summary": "navigated", "captureId": "cap_..." }
  ],
  "ok": true,
  "answer": "The page title is \"Example Domain\"."
  // or, when the message asked for information and none could be produced:
  // "answer_unavailable": "The page could not be read back, so there is no answer."
}

How to read a plan-executed turn:

  • results holds every step that ran, in order, each with the step itself as results[i].intent. Read steps from there. intents is every step the turn attempted, across every plan it made — a turn that looked at the page and planned again contributes all of its plans — so intents is longer than results whenever a plan was abandoned part-way, and the two arrays need not line up by index.

  • An entry of results carries only the fields documented on this page. The browser device’s own internal result fields are never returned, and they can change without notice, so build only on what is documented here.

  • A plan runs at most 8 steps. A task that needs more takes more planning rounds within the same message: the agent looks at the page the last plan left and plans the next steps from there, so a turn can run more than 8 steps in all. A message makes a limited number of planning rounds; a task still unfinished after them comes back with notice_reason: "step_limit", and sending “continue” carries on from the current page.

  • answer is the answer to what the message asked (“…and tell me the total”), read back from the page after the steps ran. It appears only on kind: "plan-executed", and only when the message was read as asking to be told something (the rule is below) and the session still had enough token budget to read the page. The message is read as asking when either of two things holds:

    • its own words say so: it has a question mark (?, or ? ¿ ؟ ՞ ፧ ‽; one inside a web address does not count), or one of these English words: get, give, find, read, extract, scrape, fetch, show, tell, say, list, report, summarise (summarize), describe, quote, compare, count, verify, confirm, check, look up, what, which, when, where, who, whether, why, does, do I/we/you/they, is there, are there, how many/much/long/old/far/big — except where the words name an action instead: check out, check in, check the … box, confirm the order (or the purchase, payment, booking, reservation, e-mail address, account), show more/less, get a quote, give it 5 stars, report this post (or comment, review, user and the like), verify … by entering (or typing, clicking);
    • or the AI judges it does: on every plan the AI says whether the customer, in any language, asked to be told something found on the page — which is how a question written without a mark or an English word (in Russian, say, “tell me when the shop closes” written with no question mark) gets its answer.

    The words are matched as written; a message that only asks for actions (“open news.example and take a screenshot”) matches neither. A turn that only acts has no answer. A turn whose last step failed is still read back from the page it ended on, and its answer says when the task was not finished or the information was not on that page.

  • answer is text, written to be read by a person. It is not structured data: asking for JSON does not make it JSON, and nothing checks that its text parses. Its text can also say that the information was not on the page the turn reached, so a present answer does not by itself mean the information was found.

  • answer_unavailable takes its place when the message asked for information and none could be produced — the page could not be read back, there was no AI key to answer with, too little token budget was left, the turn used up its planning rounds before it got to the read-back, or the answering step did not complete. One sentence, in plain words. It is never present together with answer, and a message that only asked for actions has neither. It is open text: show it, do not match on it.

  • Neither answer nor answer_unavailable is ever on a clarify, refuse, stopped or logged-manual result. Both can also be missing from a plan-executed one when the message did ask a question: the agent stopped part-way to ask you something (notice_reason: "question") or declined to go on ("declined"), a verification check paused the session, the session stopped answering automated steps ("session_unresponsive"), a step is waiting for your approval (confirmation_required), or the message was not read as asking for anything. In all but the last, notice or the last entry of results says what happened instead.

    Before you assert anything about answer, check that kind is "plan-executed" and that answer is present. An assertion on an absent answer passes without checking anything: “the answer does not mention an error” holds for a clarify just as it does for a correct answer. Treat a missing answer, on a message you sent to get one, as a failed check, and read answer_unavailable, notice, clarifying_question or refuse_reason for the reason.

  • notice is present when the task is not finished: the turn stopped at a limit — about three minutes of work, the planning rounds it may make, too little token budget left, going in circles, or a next step that would have repeated an action that already ran — or it could not work out the next steps, or it could not read the page to plan the next step, or the session stopped answering automated steps, or the agent asked you something part-way. It is one sentence saying which, and what it needs from you. Most ask you to send “continue”, and the agent carries on from the current page; the token-budget one asks you to start a new session instead (its sentence calls that a new chat), the stopped-answering one asks you to end the session and launch a new one, and the going-in-circles one asks you to say what to try differently. Treat notice itself as open text: show it, do not match on it. It is absent when the task finished, and usually when a step failed (the failed step says why) — but a turn that stopped because the session stopped answering automated steps always says so here.

  • notice_reason is that same ending in one word, present whenever notice is and never without it, so a program can branch without reading the sentence. (A refuse carries notice_reason too, but only when the refusal was not the agent’s choice: session_unresponsive, the session stopped answering automated steps before anything was planned; or budget_low, too little of the session’s token budget is left for the message — add the tokens refuse_reason names, or start a new session. There refuse_reason is the sentence.)

    notice_reason What happened What to do
    step_limit The task needs more steps than one message runs. Send “continue”.
    time_limit The message was taking too long. Send “continue”.
    budget_low Too little of the session’s token budget is left. Add tokens first (POST …/budget), then send “continue”.
    no_progress The page stopped changing and the next step would have repeated one that changed nothing. Ask a person; notice asks what to try differently.
    repeated_step The next step would have repeated an action that already ran, which could do it twice. Check the page, then send “continue” only if it is safe.
    ai_unavailable The next steps could not be worked out just now. Send “continue” to try again.
    page_unreadable Could not read the page to plan the next step. Send “continue” to try again.
    session_unresponsive This session stopped answering automated steps (the live view may still show the page), so nothing more was sent to it. End the session and launch a new one. “continue” will not help.
    question The agent asked you something part-way; notice is the question. Answer it as the next message.
    declined The agent stopped rather than carry on. Ask a person. “continue” will not change the answer.

    Five more say the session is paused — a step was refused because a verification check appeared — and nothing carries on until it is resumed. See When a check pauses the session.

    notice_reason What happened What to do
    challenge_solver_pending A verification check was handed to your configured solver (not sent today: no solver provider is offered yet). Wait for the page to clear, then resume the session and send “continue”.
    challenge_customer_handoff A verification check needs a person: no solver is configured, or it cannot solve this kind. Solve it in the live view, resume the session, then send “continue”.
    verification_email The site sent a code by email. Enter the code in the live view, resume the session, then send “continue”.
    verification_sms The site sent a code by text message. Enter the code in the live view, resume the session, then send “continue”.
    unexplained_pause The session is paused, and no check is showing (or the reason could not be confirmed). Open the live view to see the page, resume the session, then send “continue”.

    The list is open — a turn may one day end a way that is not in it — so treat a value you do not recognise, and an older server that sends notice with no notice_reason at all, the way you treat question: show notice and let a person decide.

  • ok is true when the last planned steps ran without a failure and without stopping for approval. It does not by itself mean the task is finished — check notice — and a turn that recovered from a failed step can show ok: true with that failure still in results. ok is false when a step failed (a tap that changed nothing on the page is a failed step, category no_effect, below), when a step is waiting for approval (confirmation_required, below), and whenever notice_reason is no_progress: the turn stopped because nothing was changing, so it did not succeed, whatever its steps show. A planned step that would have acted on the page but could not be sent is a failed step too (category invalid_request when the step itself cannot be sent, session_error when the session’s browser could not be reached), and ok stays false after it unless a later step acts on the page: a screenshot, a read or a pause after it does not make up for it, so the last entry of results can then be a success. ok is also false when the turn delivered nothing — no step changed the page (a tap, typing, clearing a field, a key press, opening a page, going back) or scrolled it, no screenshot was taken (a page-source capture is a read, not a screenshot) and no answer was given — unless your message only asked to wait, and when your message, in English, asks for more things to be done than distinct steps that changed the page ran (next item). Every entry of results can then be a success, and there is no failed step to find — read notice, or answer_unavailable when the answer you asked for could not be read.

  • A turn that delivered nothing is never reported as the task you asked for. When the agent’s plan would end the turn — it says the task is done, or says nothing about that — and the turn would then have delivered nothing (as above), that plan is not run as it is: the agent is asked once more to do what you asked, or to say why it cannot, and if it still does nothing you are asked instead — as a clarify when nothing has run yet, or as the notice (notice_reason: "question") when steps ran first. A turn that ends any other way having delivered nothing — the agent asked you something or declined after steps that changed nothing, say — is ok: false, with that ending as its notice. When your message, in English, plainly asks for things to be done on the page (tap, type, select, pay, check a box, confirm a booking…), each one needs its own step that changed the page: a screenshot, a read, a scroll or an answer is not it, even when the message also asks a question or ends in “?”. The same step run twice counts twice only when the message asks for it again (“tap Next, then tap it again”), and a key press with no field tapped or typed into first counts only for a key the message asks for (“press Enter”). A plan that does only part of it is sent back to the agent once; if the agent keeps it, it runs, and the turn is ok: false with a notice saying only part was done. A message that approves a held step (approve_consequential_actions) carries on the turn that held it, so the steps that turn ran before the held one count too. What you ask for only under a condition (“accept the cookies if there are any”, “log in if needed”), a question written as a few words (“Order total?”), a request to be sent or shown something (“send me the order total”, “share a screenshot with me”) and a message in another language are held only to the rule that something must be delivered. A message that only asks to wait — for a time, or for something (“wait for the payment to go through”) — is read in English only: in another language it gets that question rather than the wait.

AI responses can include usage:

{
  "decomposer_kind": "claude | deterministic",
  "model": "<string>",
  "anthropic_input_tokens": 14800,
  "anthropic_output_tokens": 160,
  "cost_usd_cents": 10,
  "turn_model_calls": 4,
  "turn_input_tokens": 6500,
  "turn_cache_read_tokens": 42700,
  "turn_cache_write_tokens": 400,
  "turn_output_tokens": 450,
  "turn_budget_debited_tokens": 11720
}

Only decomposer_kind is always present. The token counts and model appear when the model reports them, so a deterministic turn carries neither. Read them as evidence for the turn you made, not as an account total.

anthropic_input_tokens and anthropic_output_tokens describe one model call: the one that made the turn’s first plan (or its question or refusal). anthropic_input_tokens is the size of that call’s whole prompt, the part read from the prompt cache included, so it reads about 15,000 on a session’s first message and grows with the conversation. It is not what the turn cost: most of it is normally a cache read, billed at a fraction of the input price (a tenth on most models), and a turn usually makes several calls (it plans, looks at the page, plans again, and reads the page back to answer a question), which it does not count.

The turn_* fields add up every model call of the turn:

  • turn_model_calls — the calls that reached the model: every plan and re-plan, a reply asked for again, and the read-back;
  • turn_input_tokens — uncached input, billed at the full input price;
  • turn_cache_read_tokens — input read from the prompt cache, billed at the model’s cache-read rate (a tenth of the input price on most models, less on some);
  • turn_cache_write_tokens — input written to the prompt cache, billed at 1.25× the input price (2× for a one-hour entry);
  • turn_output_tokens — output, the model’s reasoning included;
  • turn_budget_debited_tokens — what the turn took from the session’s token_budget: uncached input and output at full weight, cache writes at 1.25× or 2× and cache reads at the model’s cache-read rate. It is what the turn’s calls took off token_budget_remaining: the budget never goes below 0, so on the turn that empties it this is what was left. A top-up during the turn adds to token_budget_remaining and is not counted here.

(In the example, on a model whose cache reads count 0.1× and with five-minute cache writes: 6,500 + 450 + 400 × 1.25 + 42,700 × 0.1 = 11,720.)

To read what a turn cost, use turn_budget_debited_tokens, not anthropic_input_tokens. On Driftstack’s included AI and on AI credits, cost_usd_cents is the turn’s whole charge too; on your own Anthropic key it is the cost of one call only, the same call as anthropic_input_tokens. The turn_* fields appear on Claude turns only, and are absent on a turn answered before they existed (an Idempotency-Key replay of an older turn, for example). A turn whose model calls ended without a plan to report (a reply that could not be read twice, a step held for approval and then resumed) still carries usage with its turn_* fields, without the first call’s figures. A turn that ends in an error (a 5xx answer) carries no usage, so no totals either.

When you use the bundled LLM, usage.cost_usd_cents is the flat 10 cents charged for the turn, not the model’s measured cost. It is the whole charge for that turn, and a per-turn figure rather than a running total — use cost monitoring for the account total. A turn refused before the model is called (a task the Acceptable Use Policy check refuses, or a session whose token budget is spent) counts 0. Explicit or stored BYOK responses can instead report measured provider cost when available.

A failed step ("kind": "failure") carries a human-readable reason plus a structured diagnosis your automation can branch on without string-matching the prose:

{
  "kind": "failure",
  "intent": { "kind": "interact", "action": "tap", "selector": "#buy" },
  "reason": "the browser action or pacing may have taken effect even though its result was not confirmed — inspect the current page before deciding whether to try another action",
  "diagnosis": { "category": "unknown", "retryable": false }
}

diagnosis.category is one of element_not_found, page_load_failed, condition_not_met, capture_failed, scroll_failed, session_error, invalid_request, result_too_large, element_covered, target_unverified, credential_site_not_allowed, session_unresponsive, no_effect, dialog_open, unknown. New categories are added over time, so treat a value you do not recognise as unknown and go by retryable. no_effect means a tap was made and nothing on the page changed in response — the page may not have been ready, or the control does not do what the step expected — so whatever the step was for did not happen. It is not retryable as the same step; the agent looks at the page again and tries something else, and the reason names what was tapped:

{
  "kind": "failure",
  "intent": { "kind": "interact", "action": "tap", "selector": "#apply-coupon" },
  "reason": "tapped #apply-coupon, but nothing on the page changed — the page may not have been ready, or this is not the control that does it; look at the page again and try something else",
  "diagnosis": { "category": "no_effect", "retryable": false }
}

element_covered means the control could not be reached where it is, so nothing was tapped or typed: something on the page — a cookie banner, a dialog, a sticky bar — was on top of it, or, for a tap, the control is off to the side of the screen (below). The reason says which. It is not retryable as the same step; the agent may look at the page again and close what is covering it, or bring the control into view. target_unverified means the tap was not made because it could not be confirmed, just before tapping, that it would land on the control; nothing was tapped, it is not retryable as the same step, and the agent may look at the page again and plan the step afresh. A tap the session checks just before tapping, on a control below or above the screen, is scrolled toward first and looked at again before it is checked (a tap held for approval only after it is approved; not in front of a step the AI marked as one that commits); that scroll is not a step of its own and has no result row. If the control is still off screen, the step fails target_unverified as before. A control off to the side of the screen, past its right or left edge with its centre on the screen from top to bottom (or one the browser says no scroll brings into view), is not scrolled to: no scroll up or down reaches it, and the session does not yet bring a sideways strip or carousel into view for you. Nothing is tapped. The step fails with element_covered, not retryable as the same tap, and a reason saying the control is off to the side, or off screen with no scroll bringing it into view (the reason has one of three wordings; match on the category, not the text). Bring it into view first with the page’s own next/previous arrow or tab, or reach the goal through another control. A control past the right or left edge that may also be above or below the screen is treated as a control below or above the screen: scrolled toward first, and target_unverified if it is still off screen. Coming: a later release will read such a control as off to the side too: off to the side when the scroll toward it moved nothing, and otherwise a fourth reason, saying it is off screen, to the side or above or below, that a scroll toward it may bring it into view (the same tap refused that way a second time, with nothing done on the page between, treated as off to the side); today it is not. A selector tap whose control is off the screen while the last look found an embedded frame covering the whole screen (a consent wall, for example), and that is not off to the side as above, is element_covered too (rolling out: on only for the accounts it has been turned on for so far; on every other account such a tap is target_unverified), with the reason “nothing was tapped: a notice or overlay is covering the page; close it first”; once the page may have scrolled since that look, it is target_unverified. credential_site_not_allowed means a saved credential was not typed because the page the browser was on at that moment is not an https:// page on a website the credential is saved for (or its address could not be read); nothing was typed, the reason names the credential’s label and the page’s host, and the turn ends there rather than trying again. session_unresponsive means this session stopped answering automated steps: 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 this step was not sent. The browser may still be running — the live view can go on showing the page — but the steps sent to it are not being answered. It is never retryable, and the turn stops there (its notice_reason is session_unresponsive too). End the session and launch a new one. If you send another message to the same session instead, the same quick check runs first, and its steps run only if the session answers it. dialog_open means the page has a JavaScript dialog open — an alert, a confirm, a prompt or a leave-page prompt — that is still waiting for an answer, so this step could not run; the session is healthy. If the step was a tap or typing, that input was delivered (the dialog is what it opened), so it is not retryable and must not be repeated; a read, a wait or a navigation never ran and is retryable once the dialog is answered. The step is never sent again on its own, and the session is never marked as not answering. The turn stops on the row; answer the dialog with a dialog step (the next message’s AI is shown the open dialog and does this itself) or in the live view, then continue. The reason never carries the dialog’s text:

{
  "kind": "failure",
  "intent": { "kind": "interact", "action": "tap", "selector": "#clear-cart" },
  "reason": "a dialog is open on the page (alert / confirm / prompt) — the tap reached the page and the dialog is what it opened, so do not tap again; answer the dialog (a dialog step: accept or dismiss, or in the live view)",
  "diagnosis": { "category": "dialog_open", "retryable": false }
}

When a step could not be sent to the session’s browser. Right after the API restarts, its link to the Mac that runs your session’s browser is down for a few seconds until that Mac reconnects. A step sent in that time never reaches the browser: that attempt did nothing. It fails at once with diagnosis.category session_error, retryable: true, and this exact reason; send the step again:

{
  "kind": "failure",
  "intent": { "kind": "interact", "action": "tap", "selector": "#buy" },
  "reason": "not sent: the browser for this session could not be reached, so nothing was done — send the step again",
  "diagnosis": { "category": "session_error", "retryable": true }
}

A step sent to a session that has already ended fails the same way with retryable: false and the reason “not sent: this session has ended, so nothing was done”.

The same answer is given, instead of a refusal, to a step refused only because the browser had not yet said it can do it, while the browser cannot be reached or has not yet said what it can do after reconnecting: a read of a field’s live value (property), a step that empties a field, answering a dialog or a sign-in prompt, a tap at a point, a swipe, a choice from a list, and a step that names an embedded frame. Nothing was sent, so wait about 10 seconds (the Mac reconnects within seconds of a restart and reports what its browser can do on its next heartbeat; it beats every 10 seconds) and send the step again. Once it can be reached, a browser that cannot do it refuses the step as before, and a step that is wrong on its own (a frame the step cannot take, a frame that is not on the page) is refused as before at any moment. A step that names a frame while the page’s frames could not be listed yet, on a browser that can list them, fails with condition_not_met, retryable: true (the frames are still loading): send it again shortly. A list of steps that could not be checked for secrets in time because the server was busy or restarting ("not run: the steps could not be checked for secrets in time, so nothing was sent — send them again") is likewise session_error, retryable: true. Cap how many times your program sends the same step or list again on these answers (a few tries about 10 seconds apart): a list that runs past the check’s time limit every time it is sent is answered this way each time.

When the browser’s network process crashes. The browser in a session can report that its network process crashed. From then on the session can no longer load or read pages, and opening another address does not bring it back. The step that was running at that moment fails at once with diagnosis.category session_error, retryable: false, and this exact reason. Every step sent to the session after it fails at once the same way:

{
  "kind": "failure",
  "intent": { "kind": "extract", "selector": "h1" },
  "reason": "the browser's network process crashed; this session can no longer load or read pages — start a new session",
  "diagnosis": { "category": "session_error", "retryable": false }
}

The turn stops on that step and does not plan again. Its notice is the same sentence and its notice_reason is session_unresponsive. “continue” will not help: launch a new session.

Driftstack then ends the session for you, so it no longer counts towards the sessions you can run at once. The turn that was running finishes first, with its failed step and that notice; then the session closes with closed_reason browser_crashed. A turn that is still waiting on the AI 60 seconds after the crash is stopped by the close, as your own close would stop it. Once the session is closed, GET /v1/agent-sessions/{id} reads status: "closed" with closed_reason: "browser_crashed", and a message or a list of steps sent to it is answered 409 conflict with session_status: "closed" and the same closed_reason; nothing is done. In the moments before it closes, a message is a refuse with the sentence above as its refuse_reason and the same notice_reason, and a Back or Forward step answers status: "error" with the same sentence; nothing is sent to the browser either way. Ending it yourself (DELETE /v1/agent-sessions/{id}) is safe at any point and answers as for any closed session.

retryable: true means automatic replay of the same step is considered safe. false means never replay automatically: an invalid request must change, while an outcome-unknown action or pacing may already have taken effect and requires state inspection before any deliberate next action. It does not prove that the action succeeded or failed. This rule applies to navigate, interact, scroll, and behavioral_pause whenever Driftstack cannot confirm whether the action ran. Read-only capture remains eligible for bounded automatic replay.

// "clarify" — the AI needs more info. It asked before any step ran, so
// nothing was read back: no "answer" and no "answer_unavailable".
{
  "kind": "clarify",
  "session": { ...AgentSession },
  "clarifying_question": "Which page should I capture — the home page or the pricing page?"
}

// "refuse" — no step was planned or run (see "When a turn is refused" below;
// the turn's tokens are still charged): the request is outside the Acceptable Use Policy, the AI declined it,
// or the AI was briefly unavailable (the refuse_reason asks you to retry).
// When the session stopped answering automated steps before anything was
// planned, it also carries "notice_reason": "session_unresponsive" — end the
// session and launch a new one. When too little of the session's token
// budget is left for the message, it carries "notice_reason": "budget_low" —
// add the tokens refuse_reason names, or start a new session. No step ran: no "answer" and no
// "answer_unavailable".
{
  "kind": "refuse",
  "session": { ...AgentSession },
  "refuse_reason": "This site's terms of service explicitly forbid automated scraping."
}

// "logged-manual" — mode='manual' pass-through; nothing planned or executed
{
  "kind": "logged-manual",
  "session": { ...AgentSession }
}

// "stopped" — the turn was stopped with POST /v1/agent-sessions/{id}/stop
{
  "kind": "stopped",
  "session": { ...AgentSession },
  "intents": [ { "kind": "navigate", "url": "https://example.com" } ],
  "results": [ { "kind": "success", "intent": { ... }, "summary": "navigated" } ],
  "ok": false,
  "notice": "Stopped after step 1 of 3, as you asked. The steps above are what ran; nothing after them was sent, and the task is not finished.",
  "stopped_during": "executing"
}

A stopped turn lists only the steps that ran — intents never includes a step that was still to come. See Stop the running turn.

Any message can come back as a clarify, a plain question included. The agent asks instead of planning when it judges the message too vague to plan against — no clear action, no clear page, or more than one way to read it — or needs a value only you can give. Nothing has run at that point, so there is no answer; reply to clarifying_question as your next message.

Step results come in three kinds: success (with summary, captureId for a screenshot — see Fetch a captured screenshot — and warning when there is something worth knowing, below), failure (with reason and diagnosis, above), and confirmation_required (below).

A successful step carries a warning when there is something worth knowing about it; when there is nothing to report, the field is absent. It has three kinds today. http_error_status: a navigation reached the site and the site answered with an HTTP status of 400 or above, carried in status, and the summary says the same in words:

{
  "kind": "success",
  "intent": { "kind": "navigate", "url": "https://example.com/old-page" },
  "summary": "navigated to https://example.com/old-page — the site answered 404 (this address may not exist)",
  "warning": { "kind": "http_error_status", "status": 404 }
}

The step still succeeded, because the status alone does not say whether the page can be used: a 403 or a 503 may be a page asking to sign in or to complete a verification step, and some sites serve their whole page under a 404. The agent is shown the same status when it plans what to do next. New warning kinds are added over time, so treat one you do not recognise as a note and read summary. A navigation that could not load at all is still a failure, with diagnosis.category page_load_failed.

effect_unknown: a tap was made, and whether it changed anything on the page could not be checked. The step ran; that it did what it was for is not known, so do not count on it until the page shows it. The summary says the same:

{
  "kind": "success",
  "intent": { "kind": "interact", "action": "tap", "selector": "#next" },
  "summary": "tapped #next (whether it changed anything on the page could not be checked)",
  "warning": { "kind": "effect_unknown" }
}

A tap known to have changed nothing is never a success: it is a failure with diagnosis.category no_effect (above).

dialog_opened: a tap was made, and the page opened a JavaScript dialog — an alert, a confirm, a prompt or a leave-page prompt — after it was sent, where none was open before. The dialog is what the tap did, so do not tap again: answer it with a dialog step. dialog names which one when the session reported it (alert, confirm, prompt, beforeunload or http_auth), and is absent when it did not. Neither the summary nor the warning carries the dialog’s text:

{
  "kind": "success",
  "intent": { "kind": "interact", "action": "tap", "selector": "#remove-item" },
  "summary": "tapped #remove-item — it opened a dialog on the page (confirm); answer it with a dialog step",
  "warning": { "kind": "dialog_opened", "dialog": "confirm" }
}

A dialog that was already open when the tap was sent is not this: the tap is not made, and the step is a failure with diagnosis.category dialog_open (above).

page_still_loading (rolling out: on only for the accounts it has been turned on for so far; on every other account such a wait is still a failure, condition_not_met): a wait for idle ran out its time while the page was still busy loading, and a look at the page right after it found the page’s content already showing, so the steps after it ran. A page with nothing on it when the wait runs out is still a failure (condition_not_met), and so is a verification check or an error page; any page a navigate the site answered with an error status opened, until the browser reports that page loading again (or, from a browser that reports no page loads, until the next step that may change the page); and a wait that ran out while a new page was still loading in place of the one on screen (also when it is the same address, such as a reload or a sign-in form sent to its own address; or the browser reported one starting while the page was looked at), or before the browser reported its last page load settling on the address it started loading, since the content showing may be the page being left (so a load the site redirected to another address, and at times a back or forward, stays a failure until the next page load). This is read from the page loads the session’s browser reports, so a session whose browser has reported none keeps the failure too. A wait for selector_visible is never this:

{
  "kind": "success",
  "intent": { "kind": "wait", "condition": "idle" },
  "summary": "waited 5 s; the page is still busy loading, but its content is showing — carried on",
  "warning": { "kind": "page_still_loading" }
}

A tap on the page that lands on an embedded frame — a tap_at whose point the browser finds on an <iframe> or <frame> element (a control drawn inside one), or a tap whose selector selects such an element by its tag (iframe, #pay iframe[name="card"]; one named only by its id or class is not recognised) — is effect_unknown (or dialog_opened, when the page opened a dialog after it), never no_effect and never a plain success: what the page shows cannot say what happened inside the frame. Its summary ends “(it landed on an embedded frame, and whether it changed anything inside the frame could not be checked)”. To act on a control inside a frame, name the frame in the step (see Steps inside an embedded frame).

Typing adds to what the field holds. A type step does not replace the field’s text: it adds its text to what the field already holds. Send the same list of steps again under a new Idempotency-Key and the text is typed a second time ([email protected]@example.com). To set a field, send a type step with an empty value first (it clears the field) and then the new text, or type the whole value once. A retry under the same Idempotency-Key replays the first answer and types nothing. The agent is told to plan that clear itself when it replaces a field’s text; check results for a cleared … step (cleared #email) before the new text. A step that types a saved credential or a session secret always clears the field first.

A type step whose value is an empty string clears the field it names: the session deletes what the field holds, one key at a time as a person would, and types nothing. Its summary says so, for example cleared #email, never typed. To replace what a field holds, send a type step with an empty value and then one with the new text. A field that cannot be emptied key by key — one holding over 200 characters, or one whose page puts characters back as they are deleted, such as a fixed +31 or $ prefix or an input mask — is not cleared, and the step fails with nothing typed. A session whose browser cannot empty a field refuses the step without sending it, and the turn stops there. A type step that types a saved credential clears the field the same way first, so the credential replaces what the field held.

A type step’s success says the text was typed into the field its summary names. It is not a promise that the field holds the text afterwards: a page can reject, reformat or clear what was typed, and any other type step adds to whatever the field already held. Two checks read a plain field back, where the browser can read a field’s live value (never a secret, a saved credential or a field inside a frame). A field that reads back with a stray character or two — a typing slip — is cleared and typed again once, and a field that still differs fails the step with a reason starting typed_value_mismatch:. And in a list of steps you send with POST /steps, a field that held the text and then no longer showed it, or never showed it, is said in the step’s summary (see there); the step still succeeds. Anything else a page does to the text is not checked. A type step that could type only part of its text is a failure, never a success.

Closed sessions return 409 Conflict; start a new one (optionally with continue_from_agent_session_id). When a turn ends the session, the 409 carries session_status: "closed" and GET /{id} shows why in closed_reason. When the session closed because its history could not take a call (closed_reason: "transcript-limit"), the 409 also carries warning: { "code": "history_full", "message": "…" }. If close or pause wins after model or browser work has already settled, that terminal 409 retains the same consumed tokens_consumed, usage, and redacted partial_results evidence described above. Treat it as outcome-known evidence for those listed steps, never as an invitation to replay them in a replacement session.

When the caller is using the bundled LLM and the account has reached its monthly bundled-LLM spend cap (bundled_llm_monthly_cap_usd_cents), the turn returns 402 Payment Required (BundledLlmBudgetExhausted) with spent_cents and cap_cents extensions. (The separate per-session token_budget is not a 402: when a turn uses the last of the session’s token budget, the session closes with closed_reason: "budget-exhausted" and the turn returns 409 conflict with session_status: "closed".)

If Anthropic rejects your own key (invalid, revoked, or a billing refusal), the turn fails with 502 byok-anthropic-required and key_rejected: true. If Anthropic refuses the account behind your key instead (its credit balance is too low, or the organization is disabled), the turn ends with 500 internal. Test a stored key on the dashboard’s Settings page, and check the Anthropic account’s credit, before sending the turn again — retrying without fixing either fails the same way.

When a turn is refused

kind: "refuse" means no step was planned or run: nothing on the page was tapped, typed or read back, and there is no answer. The session stays active. The turn is still a turn: the refusal is added to the session’s transcript, and the tokens used to read the message before refusing it are charged to the session’s token budget like any other turn (session.token_budget_remaining in the response shows what is left). A refusal the AI made is charged, a refusal under the Acceptable Use Policy can be, and one because the AI was briefly unavailable, or because the session stopped answering, is not. It is a normal answer (200), not an error, and refuse_reason says why in plain words — show it, do not match on it. A turn is refused in these cases:

  • The message asks for something the Acceptable Use Policy does not allow (bypassing a captcha, a credential attack, and the like). The same message is refused the same way every time; sending it again or rewording it does not change that.
  • The AI declined the message on its own judgement. This is a judgement, so it can vary: the same message can be answered once and declined another time, and a reason such as “I can’t help with that request.” comes from here too. When the request is one the Acceptable Use Policy allows, send it again, or reword it to say plainly what to read or do — that usually works. A question about a control on the page — whether a pay, buy or delete button is there, enabled or greyed out, or what it says — asks only for a read of the page; if one is declined, ask it again.
  • The AI was briefly unavailable. refuse_reason says so and asks you to send the message again.
  • The agent could not plan the message. The AI answered in a form that could not be used, and again when asked once more (if the turn had time left to ask). refuse_reason says nothing was done and asks you to send the message again. The answers were made, so their tokens are charged.
  • The session stopped answering automated steps before anything was planned. The refusal then carries notice_reason: "session_unresponsive"; sending the message again will not help — end the session and launch a new one.
  • Too little of the session’s token budget is left for the message. It was not read and nothing is charged. The refusal carries notice_reason: "budget_low" and refuse_reason names how many tokens to add (POST /v1/agent-sessions/{id}/budget); add them and send the message again, or start a new session.

Because no step ran, sending the same message again cannot repeat anything on the page. It is a new turn, though, and is charged the same way: a message the Acceptable Use Policy refuses is refused every time, so do not resend that one. Send it with a new Idempotency-Key: the same key replays the refusal you already have. A request that needs a person’s decision (a purchase, a payment, an account deletion) is not meant to be refused for that reason: the turn runs up to that step and halts for your approval instead (see Confirming a purchase, payment or account deletion).

Confirming a purchase, payment or account deletion

When the agent recognises a step that would make a purchase, a payment or delete an account, the turn halts before it. That step does not run, ok is false, and the last entry in results is:

{
  "kind": "confirmation_required",
  "intent": { "kind": "interact", "action": "tap", "selector": "#buy" },
  "category": "purchase",
  "matchedText": "Buy now"
}

category is one of purchase, payment, account_deletion. To approve, send the next message on the session with the approval — usually the same user_message again:

{
  "user_message": "<the same message>",
  "approve_consequential_actions": [{ "category": "purchase", "matched_text": "Buy now" }]
}
  • Copy matchedText from the result into matched_text: the result field is camelCase and the request field is snake_case. The SDKs rename it for you: pass { category, matchedText } to approveConsequentialActions in TypeScript, the held step itself in Python, and driftstack.ApprovalFor(step) in Go.
  • It must be the very next message on the session. That turn carries on from the step that halted, without planning the task again. If any other message arrives in between, the approval does not apply: the agent plans afresh and halts again at the same kind of step.
  • An approval covers actions with that category and text. A later step with different wording halts again and needs its own approval.
  • The approval is part of the request, so send it with a new Idempotency-Key.
  • A pending approval does not survive a server restart or a deploy. The halted plan is held in the server’s memory only, because its steps can hold full addresses and typed values that Driftstack does not store. If the server restarts before you approve, the approval runs nothing: the turn answers with a clarify question saying the plan was cleared, and you send the task again.

Do not approve automatically in an unattended job; have a person decide.

Recognition is best-effort. It works from what the page shows and from the agent’s own reading of the step, so a site that presents one of these steps in an unusual way may not be recognised. Treat the approval step as a safeguard, not as a guarantee that a purchase, a payment or an account deletion can never happen without it — and do not give a task more authority (saved payment methods, signed-in accounts) than you would give an assistant you were not watching.

Streaming the turn (SSE)

Send Accept: text/event-stream on the message request and the turn streams instead of blocking. The official SDKs always do this. It differs from the JSON response in ways worth writing a client around:

  • Heartbeats are SSE comments, not events. The stream opens with : stream open and emits : heartbeat <ISO-8601> about every 15 seconds. Lines beginning : carry no event name and no data; they keep the connection open while the agent works.

  • Progress events arrive before the result. Each is an SSE event with a JSON data: line. Reading them is optional, new names are added over time, and a client must ignore any event name it does not recognise:

    Event data
    phase { phase } — planning, starting_browser, executing, reading_page or answering; later rounds of a turn add segment and cause
    plan { total, intents, labels } — the steps about to run, with a short caption for each; later rounds add offset
    step_start { index, total, label } — a step is starting
    step { index, result } — a step finished; result has the same shape as an entry of results
    answer { answer } — the read-back answer
    notice { notice, notice_reason } — the same sentence the result carries, with the same one-word reason when it has one
    response { status, body } — the result; always the last event
  • One terminal frame, always named response. The stream ends with event: response whose data: is JSON { status, body } — the HTTP status the JSON lane would have returned, and the same body. It is the only authoritative result.

  • Errors arrive inside that frame, not as a status code. An invalid body or an unknown session answers 200 at the HTTP layer and reports the failure as the status field of the terminal envelope. Branching on the response status alone will read every one of those as success; read status from the payload.

  • Authentication, scope and request-rate failures are ordinary HTTP errors. A missing or invalid key (401), a key without the write scope (403) and the request-rate 429 are decided before any stream exists, so they come back as plain statuses with no stream. Every other outcome — including the 429 for too many turns running — arrives in the response frame.

  • Closing the connection does not cancel the turn. It keeps running. To cancel it, call POST /{id}/stop. To recover the result, send the request again with the same Idempotency-Key: it returns 409 conflict with idempotency_status: "in_progress" until the turn finishes, then replays the result.

Turn and session limits

  • Time. A turn stops starting new steps after about three minutes. A step that is already running always finishes, and the answer may still be read after that, so a turn can take longer; allow several minutes and use the stream. A turn plans at most six times. When a turn stops at one of these limits it returns plan-executed with a notice; send “continue” to carry on from the current page.
  • Without the stream, one request has 60 seconds. A message sent without Accept: text/event-stream is one plain HTTP request, and the gateway in front of the API ends any plain request after 60 seconds with 504. That body is the gateway’s, not the API’s answer (it may not be JSON), so do not read it as the turn’s result. On a session still starting, the wait for it counts against the same 60 seconds. The turn is not cancelled: it keeps running, its result goes into the session transcript, and, if the request carried an Idempotency-Key, its response is stored under that key. For turns that may take longer, use the stream. If a plain request ends with that 504, send the same request again with the same Idempotency-Key: it returns 409 conflict with idempotency_status: "in_progress" while the turn runs, then replays the stored response (or a final 409 with code: "response_not_kept" if it could not be kept). If it still answers in_progress several minutes later, read the session transcript before you use a new key. So send plain messages with an Idempotency-Key: without one, a resend is refused with turn_in_progress while the first turn runs and, once that turn has finished, runs the task again as a new message.
  • One turn at a time per session. A message sent while another is still running returns 409 conflict with turn_in_progress: true.
  • A session that is still starting. A message sent before the session is ready waits for it, for up to 45 seconds on the streamed response (20 seconds without Accept: text/event-stream), and then runs. If the session is still starting after that, the message returns 409 session-not-ready with retry_after_seconds: 5 and a Retry-After header carrying the same 5: “The session is still starting. Try again in a few seconds.” Nothing ran, so send the same message again (the same Idempotency-Key is fine).
  • Turns running at once, per account. At most 3 AI turns run at once across your sessions, and at most 3 of them on Driftstack’s included AI. Either ceiling refuses the next turn with 429 rate-limited, retry_after_seconds: 5 and a Retry-After header carrying the same 5, or, depending on how your account’s AI is billed, with 429 concurrency-limit and ai_tasks_in_flight: true. No step ran, so the Idempotency-Key is free: wait for a turn to finish, then send the same message again. The wait is a guess at how long a turn takes, not a promise — a turn is usually tens of seconds, so expect to wait again.
  • Message rate. Messages have their own agent_sessions:message rate-limit bucket per plan; lists of steps draw on agent_sessions:steps and tab calls on agent_sessions:tabs instead — see Rate limits.
  • History. The history the agent works from holds up to 256 entries or 1 MiB (a turn usually adds two or three entries). When the next message would not fit, the oldest entries are dropped from it and the session goes on: your first task and the newest entries are kept, and the transcript stream still returns the dropped entries (see Resource shape). Only a single message that needs more room than the history has after a drop, or a session that has already dropped the most it can (4,096 entries or 16 MiB), closes the session with closed_reason: "transcript-limit"; the message then returns 409 conflict with session_status: "closed". Carry on in a new session with continue_from_agent_session_id: it starts with your first message and the most recent part of the history, up to half the limit (128 entries and 512 KiB), so there is room to keep going. Watch session.history.entries_left on each answer: while history.nearly_full is true the answer also carries warning: { "code": "history_nearly_full", "message": "…" } (older entries will soon be dropped; you need do nothing), and the 409 a message or a list of steps gets from a session that closed this way carries warning: { "code": "history_full", "message": "…" } beside closed_reason. warning.code is an open string: match the codes you know and show message for any other. An answer replayed for an Idempotency-Key is the stored one, so its numbers are those of the original call.
  • Token budget. Each turn spends model tokens from the session’s token_budget. When too little is left to plan again, the turn ends with a notice: add tokens first, then send “continue” (a message sent before the top-up is refused). When a turn uses the last of it, the session closes with closed_reason: "budget-exhausted".
  • How long a session stays open. A session with nothing happening in it is closed (closed_reason: "idle_timeout"): after about 5 minutes without a step or input in ai or pair mode, 30 minutes in manual mode. A session running a step, a paused session and one still starting are not counted as idle; an AI turn’s planning between two steps is. Reading the session (GET /v1/agent-sessions/{id}, the list) does not count as activity. A create can choose a shorter limit with idle_timeout_seconds. A session created in manual mode is closed at 4 hours (max_duration). Any session still open about 12 hours after it was created is closed (orphaned-lifetime), however busy it is. On the free trial a session is closed at 20 minutes.
  • Idempotency. With an Idempotency-Key, every response after the request is accepted — errors included — is stored for that key and replayed on a retry. A replay is the STORED response: each step’s summary and reason are the stored step lines (the step and its site, no page text), a navigate step and initial_url keep only the address’s site (https://shop.example.com/), a type step carries no typed value (it is marked sensitive: true or value_omitted: true), and a value read off the page is not repeated. Reuse a key only when you got no response, a gateway 504 (above), or 409 with idempotency_status: "in_progress"; after any other response, send a new key once you have fixed the cause or waited.

Close

DELETE /v1/agent-sessions/{id}

Sets status='closed' with closed_at stamped and closed_reason: "customer-closed", ends the session’s browser, and returns 204 No Content. Idempotent: closing a closed session also returns 204. Closing frees one of your open-session slots, and a profile-backed session saves the profile’s cookies and storage as it ends — close in a finally block.

Closing also stops the turn the session is running, if it is running one. The AI call in flight is stopped, and the POST /message that started it answers kind: "stopped" with the steps that ran and a usage covering what that call had used when it stopped — the same answer Stop gives, so a close never leaves you paying out a call you closed the session to cut short. A close does not fail, or wait long, on any of this: with nothing running it answers straight away, a turn that will not stop is left to finish on its own rather than holding the close open, and closing stays safe to repeat.

Optionally say why with the reason query parameter, for example DELETE /v1/agent-sessions/{id}?reason=user_stop. One of user_stop (a person stopped it), new_chat, opened_other_chat or chat_deleted (an app ended it for a chat change), signed_out, switched_account or app_quit. It is stored as the session’s customer_close_reason, and the session’s closed_reason_detail then says which it was, so whoever reads the session later can tell a deliberate stop from an app that closed it. Without it, customer_close_reason is null. Only the call that actually closes the session records it: closing an already-closed session changes nothing, its reason included. Any other value is refused with 400 and the session is not closed.

Reopen an ended session

POST /v1/agent-sessions/{id}/reopen

Starts a new agent session with the configuration of one that has ended, so you can pick a closed run up again without assembling the create body yourself. The request body is empty ({}, or none).

The new session belongs to the same account and runs with the ended session’s:

  • proxy (proxy_id) — the one its traffic was going out through when it ended;
  • profile (profile_id), when it ran one;
  • device (archetype). A device drawn at random is usually the same device again (a new one is drawn if that device is no longer offered at random) and still reads archetype_source: "random"; a device that came from the profile is the profile’s;
  • mode, model, token budget (token_budget_total) and stop_on_exit_ip_change.

Nothing of the ended session is carried over: no transcript, no steps, no page, no cookies. The new session starts with an empty transcript and a full token budget. Driftstack does not keep what happened in a session, so there is nothing to restore beyond its configuration — a profile-backed session still starts from the profile’s saved cookies and storage, exactly as a new launch of that profile does. The start page (initial_url) and a fixed geolocation sent in the original create are not kept either, so the new session opens on the default page with its location derived from the proxy (or the profile’s own location). network_detail is not kept either: create a new session to ask for request detail again. To carry the conversation over instead, create a session with continue_from_agent_session_id.

The answer is 201 with the new session, whose resumed_from names the ended one:

{ "id": "agt_<new-uuid>", "status": "active", "resumed_from": "agt_<ended-uuid>", "transcript_length": 0, ... }

When it can be reopened. Once it has ended — whatever ended it: you, an idle timeout, a time limit or a failure — and for 7 days after its closed_at. Otherwise nothing is started:

  • 409 with code: "session_not_ended" — the session is still running;
  • 410 (type session-destroyed, code: "reopen_window_passed", reopen_window_days: 7) — it ended more than 7 days ago; create a new session instead;
  • 409 with code: "reopen_configuration_unavailable" — its configuration cannot start a session today: it ran without a proxy of yours, its profile was deleted, or a device or model it used is no longer offered (fields names which);
  • 404 — the id is not a session you can access (another account’s id gets the same 404 as one that does not exist). Who may reopen is who may act on the session: the account it belongs to, or an admin of that account’s team.

A reopen is a new session. It counts against your concurrent-session limit, your plan and the free trial’s session time exactly as a create does, gets the same refusals (429 concurrency-limit, 429 tier-limit with code: "trial_session_time_used", 402 trial-ended, 503 with code: "dispatch_no_live_node" when no device is free, 503 maintenance during a booked maintenance window, the proxy check’s 422, and the rest), and is billed as a new session. Send an Idempotency-Key to make a retry safe: the same key replays the session the first request started.

curl -X POST https://api.driftstack.dev/v1/agent-sessions/$SESSION_ID/reopen \
  -H "Authorization: Bearer $DRIFTSTACK_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

From an SDK: client.agentSessions.reopen(id) in TypeScript, client.agent_sessions.reopen(id) in Python and client.AgentSessions.Reopen(ctx, id, nil) in Go.

Add tokens to a running session

POST /v1/agent-sessions/{id}/budget

Adds tokens to a session that is still running, so a long job does not have to start over in a new session. The session keeps its page, cookies and transcript.

{ "add_tokens": 500000 }

add_tokens is the only field: a whole number above 0. It is added to both token_budget_total and token_budget_remaining in one step, and the answer is 200 with the session and its new numbers. If a turn stopped because the budget ran low (notice_reason: "budget_low"), top up first, then send the next message (for example continue) and the agent plans again with the new budget. A message sent before the top-up is refused without running, and says how many tokens to add.

  • The new total may not pass 10,000,000 tokens. Past that the answer is 409 with code: "token_budget_cap_exceeded", token_budget_total (the total now) and max_add_tokens (the most you can still add); nothing is added.
  • Only a session that has not ended (status provisioning, active or paused) can be topped up. A session that has ended is 409 with code: "session_not_running" (session_status, and closed_reason when it has one). That includes a session the budget itself ended (closed_reason: "budget-exhausted"): top up before the budget runs out, or reopen it, which starts a new session with the ended one’s total, top-ups included.
  • Top-ups sent at the same time never lose one another’s tokens, and together they never pass the limit: near 10,000,000, the one that would pass it is refused with token_budget_cap_exceeded and adds nothing.
  • A top-up that arrives while a turn is spending the session’s last tokens can lose: the turn still closes the session (budget-exhausted). Top up before the budget is that low.
  • A top-up is not idempotent: if you retry one whose answer you lost, the tokens are added again (check token_budget_total with a GET first).
  • A body with any other field, or an add_tokens that is not a whole number above 0 (or is over 10,000,000), is 400 and nothing is added.
  • 404 — the id is not a session you can access (another account’s id gets the same 404 as one that does not exist). Who may top up is who may act on the session: the account it belongs to, or an admin of that account’s team, with a key that has the write scope.

It works the same whether the session runs on your own Anthropic key or on Driftstack’s included AI. Each top-up is recorded in the account’s audit log.

curl -X POST https://api.driftstack.dev/v1/agent-sessions/$SESSION_ID/budget \
  -H "Authorization: Bearer $DRIFTSTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"add_tokens": 500000}'

From an SDK: client.agentSessions.addTokens(id, 500000) in TypeScript, client.agent_sessions.add_tokens(id, 500000) in Python and client.AgentSessions.AddTokens(ctx, id, 500000) in Go.

Stop the running turn

POST /v1/agent-sessions/{id}/stop

Stops the turn a POST /message started, without closing the session. Anyone who may send a message to the session may stop its turn; a session you cannot access answers 404, as it does everywhere else. The request body is empty ({}).

It requests the stop and returns at once — it does not wait for the turn to wind down:

// 202 — a turn was running and has been asked to stop
{ "status": "stop_requested", "session_id": "<id>" }

// 200 — nothing was running, so there was nothing to stop
{ "status": "no_turn_running", "session_id": "<id>" }

Stopping is idempotent: calling it again, or calling it when nothing is running, is always safe.

A stop sent right after the message is covered too: the turn counts as running from the moment the message is accepted, before any planning starts. If you get 200 no_turn_running while your own POST /message is still waiting for its answer, either the turn has just finished (its response is on its way) or the message has not reached the turn yet; asking again a second later is safe and settles which. A 503 (feature-unavailable) carrying stop_unconfirmed: true means we could not confirm the stop and the turn may still be running; call it again. A 503 of the same type without that field means AI chat is not enabled on this deployment, and calling again will not help.

The SDKs return the { status, session_id } body so you can branch on it: agentSessions.stop(id) in TypeScript, agent_sessions.stop(id) in Python (sync and async) and AgentSessions.Stop(ctx, id) in Go. Each raises the 503 as the typed feature-unavailable error, with stop_unconfirmed among the extra fields, and none of them retries a stop by itself. Because message() blocks until the turn ends, call stop from another thread, task or goroutine.

The turn itself ends on its own POST /message response, which comes back as kind: "stopped" with the steps that ran and a notice saying how far it got (or, if the turn was already finishing, as its ordinary result). That response — not the 202 — is your signal that the session will accept the next message; send it after the response arrives. On the streaming lane the terminal response frame carries the stopped body, preceded by a notice frame with the same sentence.

What happens to the work in progress:

  • Nothing new starts. No further step is sent to the browser and no further planning call is made once the stop is observed.
  • A step already running is not abandoned blind. If a tap, a typed value or a navigation was already on its way to the browser, the turn waits a short, bounded time (about fifteen seconds) for its result and reports it. If it does not answer in time, the step is reported as a failure with diagnosis.category: "unknown": it may have happened. Check the page before doing it again. A step that only reads or waits is cut short at once.
  • A model call in progress is ended, and what it used is still counted in usage and against the session’s token budget.
  • The read-back is skipped. If every step had already run, the turn ends without answering the question; the notice says so.

Run a list of steps

POST /v1/agent-sessions/{id}/steps

Runs one to eight steps you write, in order, exactly as written: no AI planning, no AI read-back of the page, and none of the session’s AI budget used. Use it when your program already knows what to do — the same steps a message’s intents list, sent directly. Each step has the AgentIntent shape of the OpenAPI document, the same shape a turn reports in results[i].intent.

This is how you drive a hosted session step by step. Every hosted session runs through a proxy of yours, and the step-by-step operations of /v1/sessions (navigate, interact, wait and the rest) answer 503 driver-not-integrated on such a session. Use an agent session instead: POST /message for an AI turn, POST /steps for steps you write.

{
  "steps": [
    { "kind": "navigate", "url": "https://example.com/search" },
    { "kind": "interact", "action": "type", "selector": "#q", "value": "blue shoes" },
    { "kind": "interact", "action": "press", "value": "Enter" },
    { "kind": "capture", "capture": "screenshot" }
  ]
}

steps uses the same vocabulary as intents on a message’s answer (navigate, interact, wait, capture, scroll, behavioral_pause, back, extract, tap_at, dialog). Every step goes through the same checks a message’s steps go through, and is done the way a person on the phone does it: a tap is a touch, typing uses the on-screen keyboard.

A type step with text adds to what the field holds. Sending the same list again under a new Idempotency-Key types the text again after what is already there. A type step with an empty value clears the field: send an empty one, then the new text, to replace what the field held, or type the whole value once. A step that types a saved credential or a session secret clears the field first. A saved credential or a session secret is typed with its placeholder ({{credential:cred_…}} or {{credential:sec_…}}), and only on one of its websites.

After a navigation, the first tap, tap_at, type or key press waits for the page to settle: before the first of those steps on a page that is new since the last one (a navigate, a back, or a page load the browser reported), the session waits until the page has finished loading and its network is quiet, for at most 3 seconds (5 with the time to hear back from the browser), then runs the step either way. A page whose document has been read and shows content, but which is still fetching other files, counts as loaded 3 seconds after its document was read, for this wait and for an idle wait step (rolling out: on only for the accounts it has been turned on for so far; on every other account such a page counts as loaded only once it has finished loading). A session’s first list of steps, and any list after the page changed (an AI turn that navigated, for one), gets this wait before its first such step even with no navigate in it. An idle wait step of yours that the browser answers as come true counts as the page having settled, and no wait of the session’s follows it on that page (a selector_visible wait does not count: an element can show before the page’s script is ready). A change of view with no page load (a single-page app’s own route change) gets none: add a wait step for it. When that wait ran out and the step it preceded is a plain type step whose field held its text, one more wait of the same length follows that step, and none after that: two waits, 10 seconds, per new page at worst. A browser that has stopped answering holds each of these waits for those 5 seconds and no longer; on a session whose browser cannot cancel a command it has started, the step after each such wait can then be held up to 60 seconds more before it is sent, and its own checks before the tap or the keys may be skipped. These waits use the turn’s own time, so a list that runs close to the turn’s time limit can stop a step earlier than it did before. On the page a session was created at, while that page is still loading, an idle wait of yours is answered at once, so the session’s own wait still follows it.

A type step in a list says what the field did, and is never failed or typed again for this check. A plain type step (text of at most 1,000 characters with no line break, no secret, no saved credential or {{credential:…}} placeholder, not inside a frame) reads its field back where the browser can read a field’s live value. Two sentences can close its summary, and the step succeeds either way:

  • “the field held the typed text and then no longer showed it; if the text is missing, the page likely re-rendered after loading: add an idle wait with a longer timeout, or a selector_visible wait for something that appears only once the page is ready, before typing” — the field showed the text right after the keys and, after that second wait, holds only what it held before them. A field that uses its text and empties itself (a code field that submits, a coupon that applies itself) reads the same way, so check what the page did before you type the text again;
  • “the field did not show the typed text; it may have used it at once, e.g. a tag or code field” — the field held only what it held before the keys as soon as they were typed.

The text itself is never quoted.

The answer is the same shape as a message’s: kind: "plan-executed" with intents (every step in order, the ones not run included), results, ok, and notice / notice_reason when the run ended early for a reason no step says (the session paused, or stopped answering automated steps) — or kind: "stopped" when you called Stop. There is no usage or answer: no model is called.

Each successful step says which page the session reported: page. When enabled on this deployment, a successful row of a list of steps carries page, the url and title the session last reported when that step’s result arrived — what Page state would have answered at that moment (on a session with several tabs, the last report from any of them). A saved credential or session secret typed in the session shows as its placeholder, as in the step’s summary (see what that does not cover):

{
  "kind": "success",
  "intent": { "kind": "extract", "selector": "h1" },
  "summary": "…",
  "page": { "url": "https://example.com/search?q=blue+shoes", "title": "blue shoes - Example" }
}

url or title is null when the session has reported none yet. page is absent when the session has reported no page state, its last report was a loading or stalled one too old to trust, live page state is not available on this deployment, the step’s text was withheld (withheld: true), the address and title could not be checked for a typed saved credential in time, or the session typed a saved credential whose value is no longer known; a failed row never carries it. A page load a step starts can be reported a moment after the step’s result, so after a tap that opens another page, add an idle wait before relying on page. The title is page text: treat it as data. Like values, page is returned in this response and never stored: the transcript and a replay of an Idempotency-Key carry the step without it. A message’s rows (POST /message) do not carry it.

A step that cannot run is a failed row, not a refused request. A step outside the vocabulary, an address that is not a full http:// or https:// address, one on this computer or a private or link-local network (such as 169.254.169.254, or a numeric form of one of those), a name that only a private network answers (one with no dot, such as http://intranet/, one with an empty label, such as http://a..b/, or one ending .internal, .local, .localdomain, .home.arpa, .lan, .corp, .home, .intranet, .private or .svc, or another name that is always this computer, such as localtest.me or lvh.me) or a nip.io, sslip.io, xip.io or traefik.me name that stands for such an address in the forms listed (127.0.0.1.nip.io, 7f000001.nip.io) or for no address at all, one carrying a user name or password, a key no iPhone keyboard has (Tab, Escape, an arrow), an interact swipe (in the vocabulary, but never a step a list takes), a tap on a native list (one is chosen from by a type step naming the option, on a session whose browser has the list picker; a type into one on any other session fails the same way), a frame the page does not have, a frame step this session’s browser cannot do, a frame named by its selector (a frame inside a shadow root) on a session whose browser cannot enter one, or a frame found by its address or name on a session whose browser cannot find one that way — each is a failed row at its place in results whose reason says why. Nothing after it is sent, the steps after it are listed in intents with no result, and ok is false. A wait that times out does not stop the run: its row fails and the next step runs, as in a message.

A capture is a screenshot or a dom_snapshot. pdf is in the step schema, but no session can make a PDF yet: a capture with pdf is a failed row whose reason says so, and nothing after it is sent. A screenshot is the screen as the phone shows it, not the whole page.

A selector must be CSS, on a wait as on a tap. A wait for selector_visible names its element by a CSS selector, as a tap, a type and an extract do. Text pseudo-classes such as :has-text() or :contains(), Playwright >> chaining, a locator with an engine prefix (text=, css=, id=, xpath=, data-testid=), XPath, a # or . with no name after it (a., #, .-) and an escaped id or class that starts with a digit (.1\:x) are not CSS, and a step that uses one is refused before it runs: its row fails with a reason that names the part that is not CSS, diagnosis.category invalid_request and retryable: false, and the step is never sent to the browser. Match on an attribute or on the page’s structure instead (button[type="submit"], form > button:first-child). A test id is an attribute like any other: [data-testid="checkout"]. So is a field’s hint text: [placeholder="Email"] matches only a placeholder that is exactly Email, letter case and spaces included, and [placeholder*="mail"] matches one that contains mail. A refused tap, type or extract stops the run like any step that cannot run; a refused wait does not, and the next step runs. A wait that names a frame is judged on its selector first: one that is not CSS is refused before the session looks for the frame or waits for it.

A comma list and a >>> path in one selector are refused. A selector with a comma at its top level and a >>> (host-a, host-b >>> button) fails before it is sent: diagnosis.category invalid_request, retryable: false, and a reason that is the step’s name followed by “a comma list cannot be combined with >>> yet: split it into separate steps, or use :is() without >>>”. A refused step other than a wait ends the list. Split it into one step per branch, or write the alternatives as :is(…) without >>>. A comma inside parentheses, […] or quotes, or escaped as \,, is not a top-level comma. A scroll by selector on a browser that cannot scroll to a selector is a plain scroll and is not refused.

Preview steps (eval accounts)

Some steps are a preview: they run only on an account the preview is switched on for (the evaluation accounts first). The AI plans with them on such an account only.

  • A scroll to the side. direction may also be left or right, for a page wider than the screen: { "kind": "scroll", "direction": "right" }. It moves the page itself sideways; a page no wider than the screen does not move, and the step fails with the page did not scroll. On any other account such a step is a failed row, as it always was (does not say whether to scroll up or down), and nothing after it is sent.
  • How far, in words. amount instead of amount_px: one_screen (a little less than the screen’s height, or its width for left and right, so some of what was showing stays in view), to_top (all the way up, only with direction: "up") or to_bottom (all the way down, only with direction: "down"), as in { "kind": "scroll", "direction": "down", "amount": "to_bottom" }. A step naming both amount and amount_px, an amount that is not one of these three, or a to_top or to_bottom with another direction is refused with a 400 naming steps.<i>.amount, and nothing is sent. On any other account an amount that is one of these words is a 400 naming steps.<i>.amount too (before, the word was ignored and the step ran as if it named no amount). The step’s line says how far it was asked to go (scrolled down to the bottom); a page longer than the browser’s longest single scroll is not reached in one step, and the line says (capped). A to_top on a page already at the top (or a to_bottom at the bottom) succeeds with scrolled 0px — the page is already at the top.
  • A wait for the page address. condition: "url_matches" with url waits until the address of the page contains that text, as in { "kind": "wait", "condition": "url_matches", "url": "/order/confirmed", "timeoutMs": 10000 }. The text is matched exactly as written: letter case counts, and it is plain text, never a pattern (.* matches only an address that holds a dot and a star). Write it as the browser writes the address: printable ASCII only, a host in its ASCII form (xn--…), a space or a letter outside ASCII percent-encoded (/caf%C3%A9, not /café). Some printable characters (", <, >, `, {, }, |, ^) are accepted but the browser percent-encodes them in a path or fragment, so a text holding one written plainly never matches there and the wait times out. Pick a text the address does not hold yet: one it already holds ends the wait at once. url is 1 to 4,096 characters (counted in UTF-16 units; an empty one would match at once), and the wait takes no selector and no frame: each of those, a url with a space or a character outside printable ASCII, and url on any other condition, is refused with a 400 naming the field, and nothing is sent. The address is read by the browser itself; nothing runs in the page. timeoutMs is rounded up to whole seconds; without it the browser waits up to 30 seconds, and never longer than 300. Like any wait, one that times out fails its row (the page address still does not contain the text asked for) and the next step runs. The stored copy of the step keeps no url, so a step list taken from a stored receipt and sent again names no url and is refused with a 400 on an account in the preview: send the url again with it, as for a type step’s text. On any other account such a wait is a failed row, as it always was (while STEP_UNKNOWN_FIELD_REFUSAL is on, its url is a 400 naming steps.<i>.url, as any field a wait does not take).

Steps inside an embedded frame

A payment form, a verification check or a chat window is often an embedded frame (an <iframe>), often from another website. A step reaches inside one by naming it in frame: a type, a tap, a wait for selector_visible, an extract with body: true (the frame’s whole text) or, on a session whose browser can read an element inside a frame, an extract by selector (and by attribute), or a capture of dom_snapshot. The path is the browser’s own numbering of the page’s frames, outermost first ([0], or [0, 1] for a frame inside frame 0) — list it with GET /v1/agent-sessions/{id}/frames:

{
  "steps": [
    {
      "kind": "wait",
      "condition": "selector_visible",
      "selector": "input[name=\"cardnumber\"]",
      "frame": [1]
    },
    {
      "kind": "interact",
      "action": "type",
      "selector": "input[name=\"cardnumber\"]",
      "value": "{{credential:sec_4be1c09d2f7a4e6b8c35d1a0f9e27b64}}",
      "frame": [1]
    },
    { "kind": "interact", "action": "tap", "selector": "button[type=\"submit\"]", "frame": [1] }
  ]
}

A wait for an element inside a frame must name that frame, as above: without frame it watches the page itself, where that element never appears. When a selector_visible wait without frame runs out its time and the last list of this page’s frames (from GET /frames, a call that names a frame, or the AI’s own look) held one, its reason ends “— if the element is inside an embedded frame, name the frame in the step (see GET /v1/agent-sessions/{id}/frames)”. On a session whose browser can read an element inside a frame, the session first counts, read-only, the same selector inside each frame that list held (at most 20, at most 5 seconds in all; nothing is typed or tapped). When it matches inside one or more, the reason says where instead: “waited 5 s; #card-number is still not visible — not in the top document; it matches inside embedded frame [2, 0] (pay.example/fields)” — up to three frames, each by its path and the host and path of its address (never its query), then how many more. Name one of them in frame. diagnosis is unchanged either way.

For a call that names a frame, the session asks the browser for the page’s frames when the call begins, and:

  • A path the browser does not list is not run. Its row’s reason names the path and points to GET /v1/agent-sessions/{id}/frames, and nothing is sent for it. A frame list taken while the page is still loading may be partial (truncated): a step naming a frame that list does not show is sent anyway, and the browser answers whether the frame is there.

  • A wait inside a frame waits for the frame itself first. A payment frame is often inserted a moment after the page loads, so a wait for selector_visible with a frame waits, within its timeoutMs (the browser’s default of 30 seconds when none), for the browser to list that frame — asking again about once a second — and then for the selector inside it, with its full timeoutMs. Only when the frame is still not there at that time is the step a failed row, saying the frame did not appear within the time (condition_not_met, retryable). Other steps inside a frame need the frame to be there already, so wait for it first.

  • After a step that may change the page — a navigate, back, tap, typing or key press — or a wait, a later step’s frame is checked again, right before it is sent, against a fresh list from the browser: a page that loaded, removed or replaced a frame renumbers the frames after it, and a path the page does not have then is not run rather than sent to whatever frame now has that number. So navigate followed by steps inside a frame can be one call, and so can a frame the page adds a moment after it loads: wait for its element on the page first (a wait for selector_visible with a selector such as iframe[name="card"] and no frame), then name it.

  • Your selector is used as written (but for an id or class that is no CSS name, read as above). It is matched inside that frame, not on the page; an element that is not there fails the step, as it would on the page.

  • What the browser cannot do inside a frame is a failed row, saying so: a scroll or press (they act on the page itself), a screenshot, and — on a session whose browser cannot act inside a frame — a tap or a wait, and — on one whose browser cannot read an element inside a frame — an extract by selector or by attribute (there only the whole frame’s text can be read).

  • An extract by selector inside a frame succeeds only when the browser confirms it read exactly that frame; otherwise the step fails and nothing of what came back is reported as the frame’s. It changes nothing on the page. With property it reads what a field inside the frame holds now — a card field’s value, its name, autocomplete or outerHTML — returned in the result’s value and never stored. A selector the frame does not have fails the step, naming the selector and the frame:

    {
      "steps": [
        {
          "kind": "extract",
          "selector": "input[name=cardnumber]",
          "property": "value",
          "frame": [0]
        },
        {
          "kind": "extract",
          "selector": "input[name=cardnumber]",
          "property": "autocomplete",
          "frame": [0]
        }
      ]
    }
  • An extract with body: true inside a frame that shows no words succeeds, and its summary says why when that is known: “extracted nothing: the embedded frame [0] shows only fields, no text: act on the fields listed under its frame row (read the page again if none are listed)” when the frame holds fields to type into or choose from (a card form: act on those fields by selector; a field’s placeholder or aria-label is not text this read returns), “… shows elements but no readable text (fields, images or a canvas), or has not drawn its content yet: act on the fields listed under its frame row, or read it again (scroll the page toward it first if it is below the fold)” when the frame shows a custom element (whose controls are drawn where a read does not reach), or images, a canvas or buttons (an input button such as a lone pay button counts) and no field or frame, or the browser reports elements but no text, and “… holds other frames: read the inner frame by its path” when the frame holds another frame and no field or custom element, or the browser reports it (name the inner one with a longer path, such as [0, 0]). Otherwise it is “… holds no readable text — it may not have loaded yet (a frame below the fold often loads only when the page is scrolled to it)”.

  • An extract by selector inside a frame whose element has no text (a field: an input holds no text of its own) succeeds, and its summary is “extracted nothing: the frame has loaded, and <selector> in embedded frame [0] has no readable text (a field holds no text of its own)”. A selector that matches nothing in the frame fails, as it does on the page. A selector of the bare document skeleton alone (html, head, body, :root, *) proves nothing, since a frame that has not loaded holds those too: it gets the frame words above.

  • A saved credential or session secret typed inside a frame is typed only when both the page and the frame are https:// pages on one of its websites, as the browser reports them at that moment, and only into the frame the call listed at that path.

A step inside a frame succeeds only when the browser confirms it was done in exactly that frame (see the frame rules above).

Naming a frame by its address or name. A number goes stale when the page’s scripts add or remove frames. A level of frame can instead be {"src": "…"} — the frame whose address or iframe src contains that text (1 to 512 characters) — or {"name": "…"} — the frame whose iframe name is that text (1 to 256 characters), as in [{"src": "js.example-pay.com/card"}] or [0, {"name": "card-number"}]. No control characters or line breaks. The browser finds the frame when the step runs; it is not checked against the frame list, and a wait waits within its timeoutMs for a match. Exactly one frame must match: none, or more than one, and the step fails with nothing typed or tapped inside a frame (“no frame on the page matches …” / “more than one frame on the page matches …”). Each level is matched only among the frames of the document the levels before it reached, so a frame nested inside another frame needs one level per frame, outermost first: a tap on a button in the inner frame of a widget is

{
  "kind": "interact",
  "action": "tap",
  "selector": "#continue",
  "frame": [{ "name": "linked-accounts" }, { "name": "linked-accounts-inner" }]
}

— [{"name": "linked-accounts-inner"}] alone matches nothing on the page, and a {"src": …} level that matches the outer frame’s address enters the outer frame, where the inner frame’s button is not. A selector the browser does not find inside the frame it entered fails the step as “no element inside frame … matched this selector”, with the document it searched (queried document: url=… readyState=…) — on a session whose browser acts inside frames and lists them, after waiting for it there once (not when the browser scrolled or dragged toward it first: that step is not tried again); a frame inside that one needs a level of its own. The same holds for the numbers from GET /frames: [0, 1], never [1], for frame 1 inside frame 0. A saved credential or session secret is never typed into a frame named this way; name it by its number. On a session whose browser cannot find a frame this way, the step is a failed row with nothing sent (“this browser cannot find a frame by its address or name yet — name it by its number from GET /v1/agent-sessions/{id}/frames”).

A frame that moved is refused, not used. Hosted payment fields often add and remove helper frames while the page is open, so between the list a number came from and the step, [2, 0] can come to name another frame. On a session whose browser checks this, a step that names a frame by its number is done only inside the frame your last GET /v1/agent-sessions/{id}/frames of this page showed at that number — even though the call lists the frames again when it begins — or, when you did not list this page’s frames, the frame the call’s own list showed there. A step that may have changed the page ends the page your list was of: a navigate or back, a tap, typing, a key press, an answered dialog, or a page that loaded during the step. A scroll or swipe of interact does not end it, unless a page load was seen during that step: you chose no new page, and the step after it is still held to your list. A list that GET /frames was still taking while a scroll or swipe of a running call was under way is not used, in that call or after it: list again after the call. A framed step that was refused, with nothing typed or tapped, does not end it — not even one saying a page load may have started — unless a page load was seen during that step. When the page has moved its frames since, the step fails with nothing typed or tapped inside any frame: its reason is “frame [2, 0] is no longer the one the last frame list showed, so nothing was typed or tapped inside it (the frame or page may have scrolled) — list frames again (GET /v1/agent-sessions/{id}/frames) and name it by its url” (diagnosis.category invalid_request, not retryable). The browser checks this again each time it re-enters the frame after a page scroll or a drag it already made, so look at the page before the next step. List the frames again and name the frame by a {"src": "…"} level holding a part of its url that only that frame has, which the browser finds when the step runs. The AI’s own steps fail the same way and its next look lists the frames again.

More than eight steps is refused whole with 400 (validation-failed); nothing is cut. A step field over its limit is a 400 naming the field (steps.2.selector), and so is an extract whose property is not one of the listed ones, or is sent beside attribute or body, or without a selector (steps.1.property), and an extract whose all is set and is not true or false, or is true and sent without a selector or beside body or property (steps.1.all, steps.1.selector, steps.1.body, steps.1.property), or whose index is not a whole number from 0 to 999 or is sent beside "all": true (steps.1.index), with the same rules as all otherwise, and any step that is not an extract and carries an index (steps.1.index: only an extract reads a match by its position); nothing is sent. "all": false reads one match, as if all were not there.

A step field its kind does not take is ignored. Each step takes the fields its kind has in the AgentIntent shape of the OpenAPI document (an extract takes selector, body, attribute, property, all, index and frame; a navigate only url). Any other field — limit or multiple on an extract, a selector on a navigate, all on a step that is not an extract — is dropped before the step runs (index on a step that is not an extract is the one exception: a 400, above): the step runs as if the field were not there, and results[i].intent shows it without it. So a field your step does not take does nothing; check your steps against the shape. A step sent back exactly as an answer listed it (in intents or results[i].intent) holds only its kind’s fields. A field sent as null is not set, and is accepted on any step ("selector": null on a navigate). commits is accepted on any step and ignored. A kind outside the vocabulary is a failed row rather than a 400, and an unknown key at the top of the body (beside steps) is ignored and listed in the x-driftstack-unknown-fields header.

Approving a purchase, payment or account deletion. A step the session stops at for approval comes back as confirmation_required, and nothing after it ran. To approve it, send the rest of the list — from the stopped step on — again, with approve_consequential_actions (the category and matched_text of that result). The approval counts only when the session’s latest transcript entry is that stopped call and the steps you send are its remaining steps (a typed value is compared only where both calls stored it; see the transcript below); otherwise it is ignored and the step stops for approval again. A message cannot resume a stopped list of steps: approving it in a message plans afresh. The field is spelled approve_consequential_actions here too: approvals and the other misspellings listed under Message are refused with 400 (validation-failed) naming the key, rather than ignored.

Who and what it shares with messages. A key with write (account keys only; a session’s control key is not accepted). The session must be under AI control: a session in manual mode, or one a person is driving in pair mode, answers 409 with ai_control_unavailable: true and nothing is written. It counts against its own agent_sessions:steps rate limit, not the agent_sessions:message one: a list of steps runs no AI, so steps and messages cannot use up each other’s rate. It counts against the same running-turns limit as a message, so a list of steps and a message never run on one session at once (409 with turn_in_progress). It takes an Idempotency-Key exactly as a message does; a key used for a message cannot be reused for a list of steps (409 with idempotency_status: "mismatch"). What a step types is not part of the key’s request: two lists that differ only in a typed value are the same request to it, and the second replays the first. Accept: text/event-stream streams the run as a message’s turn does (step, phase, plan, step_start and notice events, then one response).

A plain request is answered within 60 seconds, or it is cut. A request sent without Accept: text/event-stream waits for the whole list to finish. If the list takes longer than 60 seconds, our gateway ends the request with HTTP 504, and the body of that answer is not the JSON error format the API uses everywhere else, so do not parse it. The cut does not close the session and does not stop the steps. The list keeps running in the session, and until it ends a new message or list of steps on that session is refused with 409 and turn_in_progress: true.

A type step can take longer than 60 seconds on its own. Text is typed key by key, and our server waits for a type step for up to 60 seconds plus one second for each character of its text, and never past the list’s own limit of 5 minutes from the start of the call, before it gives the step up as timed out. A later step in the same list has only what is left of those 5 minutes (a step that starts with less than 15 seconds left is still given 15 seconds). A step that clears the field first (an empty value, a saved credential or a session secret) is waited for up to that same limit. After 5 minutes no further step starts, so a running list ends about 5 minutes after its call began at the latest, once the step running at that moment is over. So send long text with Accept: text/event-stream: the server opens the response at once, sends a heartbeat comment about every 15 seconds while the steps run, and then one response event with the result (see Streaming the turn). The runSteps call of the TypeScript SDK, run_steps of the Python SDK and RunSteps of the Go SDK already send that header. Or send the text as several shorter type steps, one call each (with a different Idempotency-Key each time, if you send one); each step adds to what the field holds.

After a 504. Do not send the same text again at once: a type step adds to what the field holds, so a repeat is typed after the first. First wait for the running list to end. Send a list with one read step, such as { "kind": "extract", "selector": "#email", "property": "value" }, and repeat it every 5 to 10 seconds while it answers 409 with turn_in_progress: true; each try counts against the agent_sessions:steps rate limit. When it is answered, it shows what the field holds now: type only what is missing. POST /{id}/stop asks the running list to stop instead of waiting for it: no further step starts, and the step that is running may still finish. If the request that was cut carried an Idempotency-Key, the same request sent again with the same key does not run the steps a second time: it answers 409 with idempotency_status: "in_progress" while the list is still running, and then replays the stored result of the first request (see Idempotency). The replay leaves out any value an extract step read, and a result that could not be stored is answered instead with 409 and code: "response_not_kept". Without a key the result of the cut request is not sent again; the session’s transcript has its entry.

The transcript. Each call adds one entry, with origin: "steps", listing the steps and their outcome; no entry is written for your request itself. The step record never keeps a typed value. (What you write in a message, and what the AI writes back, is kept as written, except card numbers, saved credentials a step typed, and passwords and one-time codes of four or more characters that a step types into a field we recognise as a password or code field — masked wherever they appear in the transcript, including your own messages, once the turn has finished — and secret parameters in web addresses. Other text a step typed, such as a name or a search, or the text of a sign-in or prompt dialog, can appear in the conversation. A recipe saved while its session is running keeps the values you gave it — see Recipes.) A stored type step keeps its field and never carries the typed value: it is marked sensitive: true, or value_omitted: true when the field was read as an open field (only a saved credential’s {{credential:…}} placeholder, or an empty value for a clear, is kept as a value), and that step’s own result row does not repeat it — in the transcript and in the Idempotency-Key receipt alike. The device receives every value as you wrote it, and your own response shows what it reported. A saved credential’s placeholder is stored as written. The next message’s AI sees these entries as steps you ran, not ones it planned. The history the agent works from holds at most 256 entries and 1 MiB, and each call adds one entry. When a call does not fit, the oldest entries are dropped from that history (the first task and the newest entries are kept) and the session goes on; this happens about once every 125 steps calls, and the transcript stream still returns the dropped entries. A dropped step is no longer in the history the AI or a recipe built from the session reads. The session closes with closed_reason: "transcript-limit", before the call sends anything, only when one call alone needs more room than the history has after a drop (a single call with several hundred KiB of text) or when the session has already dropped the most it can (4,096 entries or 16 MiB). session.history in each answer says how much room is left, and warning with code: "history_nearly_full" (fewer than 32 entries left, or the history close to 1 MiB) says a drop is near; you need do nothing. The “still running” answer below has no session, so it carries neither.

“Still running” instead of a cut request. A plain request (without Accept: text/event-stream) whose steps have started and have not ended about 50 seconds after it reached the server is answered 202 with a JSON body kind: "still-running" (and Retry-After: 10 when the call carried an Idempotency-Key), instead of being left for the gateway to cut at 60 seconds. The steps go on: nothing was stopped, failed or refused. The body has session_id; steps_total and steps_completed; running_step_index, the step running now (absent when none is between its start and its end); results_so_far, the rows of the steps that have ended, as a finished call lists them (first answer only; a value an extract step read is in its row here, once); elapsed_ms; ends_within_seconds, an upper bound by which the steps have ended; transcript_length_at_start, the session’s transcript_length before the call; final_result; retry_after_seconds, only when final_result is same_idempotency_key (without a key there is no retry_after_seconds and no Retry-After header: that call cannot be read again); and detail, a sentence to show. How many characters a type step has typed so far is not known until it ends, so it is not reported. The steps have ended within ends_within_seconds at the latest. To learn the outcome when final_result is same_idempotency_key, send the same request again with the same Idempotency-Key, at most once every retry_after_seconds: once the steps have ended it returns the stored result, and while they still run it answers the same 202 again, without results_so_far (each try counts against the agent_sessions:steps rate limit). transcript_length on GET /v1/agent-sessions/{id} going above transcript_length_at_start is a cheaper sign that the steps have ended, but some endings add no transcript entry (the session was closed, a person took control, an internal error), so do not wait on it alone. The key is compared on the session and the steps, not on typed text. A value an extract step reads after the 202 is not in the stored result. If that retry answers 409 with idempotency_status: "in_progress", the result is not available (for example the server restarted): read the session transcript, and do not send the steps again under a new key. When final_result is transcript_only (no key was sent), the per-step results of the call cannot be read later; the transcript gets its one entry when the steps end normally. Do not send the same steps again to find out whether they have ended: a new call runs as soon as these have ended, and a type step adds to what the field holds. A retry is checked against the page as it is at that moment before its key is read, so a 400 on a retry says nothing about the call that is running. POST /{id}/stop cancels the steps. The event stream never gets this answer.

The SDKs: agentSessions.runSteps(id, steps, opts) in TypeScript, agent_sessions.run_steps(id, steps, ...) in Python (sync and async) and AgentSessions.RunSteps(ctx, id, steps, opts) in Go.

Dialogs

A page can open a JavaScript dialog — an alert, a confirm, a prompt, or a beforeunload prompt asking whether to leave the page — and a site can ask for an HTTP sign-in (http_auth, below). The browser shows it as the iPhone’s own sheet, and while it is open nothing else on the page can be done: every other step fails with diagnosis.category dialog_open (see Message) until the dialog is answered.

Seeing it. On a session whose browser reports dialogs, GET /v1/agent-sessions/{id}/page-state carries the open dialog in page_state.dialog: { "kind": "confirm", "message": "Empty your cart?" } (a prompt’s starting text, when it has one, in default_text). It is null once the dialog has closed, and the key is absent when the session’s browser has never reported a dialog. The AI is shown the open dialog too, on every planning step while it is open, and answers it first. The message is the page’s own text: it is returned to you and shown to the AI. The page state is not stored; a dialog’s text that the AI or you repeat in the conversation is stored with the conversation.

Answering it. A dialog step taps OK ("action": "accept") or Cancel ("action": "dismiss"); to answer a prompt, accept with text, which is typed into its field first (at most 1,000 characters):

{
  "steps": [{ "kind": "dialog", "action": "accept", "text": "Jane Smith" }]
}

Its result says what it answered — never the text you typed:

{
  "kind": "success",
  "intent": { "kind": "dialog", "action": "accept", "text": "{{prompt text not stored}}" },
  "summary": "answered the prompt dialog: OK",
  "dialog": { "handled": true, "kind": "prompt", "action": "accept" }
}

With no dialog open the step still succeeds, answers nothing, and says so: "dialog": { "handled": false, "action": "dismiss" } with the summary no dialog was open, so nothing was answered. text with "action": "dismiss" is a 400 naming the field, and so is text when the dialog the session reports open is not a prompt or a sign-in sheet (an alert, a confirm and a leave-page prompt have no field to type into).

The text typed into a prompt is never kept. A prompt can ask for a password or a code, so the text is sent to the browser and nowhere else: the response, the step stream, the stored transcript, an Idempotency-Key replay, a recipe and every log line show the step with {{prompt text not stored}} in its place. A stored step carrying that marker is never answered — run again from a recipe, or resumed after an approval, it fails without being sent ("not answered: the text typed into a prompt is never stored, …"); send the step again with its text.

OK on a dialog that asks to pay, buy or delete an account waits for your approval. An accept of a confirm or a leave-page prompt whose message names a purchase, a payment or an account deletion — by the same words a button would be checked for (Place your order, Confirm payment, Delete my account) — stops before it is sent with a confirmation_required result, as a tap on such a button does (Confirming a purchase, payment or account deletion). Send it again with approve_consequential_actions to let it through; dismiss is never held.

A site that asks for an HTTP sign-in. When a site answers with an HTTP Basic or Digest challenge (a 401), the browser shows the iPhone’s own sign-in sheet, as Safari does, and the page shows nothing until it is answered. The session reports it as an open dialog of kind http_auth, whose message is Sign in to <host> (the host that asks, nothing more): { "kind": "http_auth", "message": "Sign in to the-internet.herokuapp.com" }. dismiss taps Cancel. accept signs in, and its text is the sign-in:

  • <username>:<password>, split at the first colon that is not inside a placeholder. Each part is either a saved credential’s whole placeholder or your own words: {{credential:username}}:{{credential:password}}, alice:{{credential:password}}, or alice:s3cret;
  • or one saved credential’s placeholder, {{credential:<handle>}}, as the whole text, whose saved value is username:password.

A username has no colon; a password may. A placeholder is resolved only when the step is sent, and only when the host the sheet names is one of the websites the credential is saved for, as a typed credential is. A placeholder inside other text, one this session does not hold, an empty username or password, and any other text fail the step without sending anything.

When the session reports that the address asking for the sign-in is plain http ("scheme": "http" on the dialog), a sign-in with any saved part is not sent ("not signed in: this site asks for a sign-in over an unencrypted connection, so a saved password is not sent, …"); one written out in full in your own words still is. Without a scheme, the address is taken to be https. The username and password go to the browser and nowhere else: the step shows {{prompt text not stored}} in every copy, as a prompt’s text does, and its summary is answered the sign-in sheet: Sign In. Signing in this way needs a session whose browser can sign in on the sheet; on any other the accept fails without being sent ("not signed in: this session’s browser cannot sign in on the site’s sign-in sheet, …") — sign in in the live view. An address written with user:pass@ is opened without it, as Safari opens it, and the site’s sheet is answered as above.

Only on a session whose browser can answer dialogs. On any other the step fails without being sent ("not answered: this session’s browser cannot answer a dialog, so nothing was sent — answer it in the live view"); answer the dialog in the live view instead. A step whose text holds a saved credential’s or a session secret’s placeholder fails the same way: a placeholder is typed only by a type step.

A tap or typing that meets a dialog is never repeated. When a tap, typing or a key press fails with dialog_open, the input may already have reached the page (the dialog can be what it opened), so retryable is false and it is never sent again on its own; a read, a wait or a navigation never ran and is retryable: true once the dialog is answered. Answer the dialog, look at the page, then decide the next step. A tap that succeeded and opened a dialog says so with warning.kind dialog_opened (see Message): answer the dialog the same way, and do not tap again.

List the frames of a page

GET /v1/agent-sessions/{id}/frames

The embedded frames of the page the session’s browser is on, as the browser numbers them now — the paths a step’s frame names in POST /steps. The session asks the browser for them when you call it: a read, nothing anyone could see on the phone, and nothing on the page changes.

{
  "frames": [
    {
      "path": [0],
      "address": "shop.example.com/promo",
      "url": "https://shop.example.com/promo?slot=top",
      "url_masked": false,
      "url_truncated": false,
      "name": null,
      "displayed": true,
      "zero_size": false
    },
    {
      "path": [1],
      "address": "pay.example.net/embed/card",
      "url": "https://pay.example.net/embed/card?client_secret=REDACTED&sid=checkout-7",
      "url_masked": true,
      "url_truncated": false,
      "name": "card-number",
      "displayed": true,
      "zero_size": false
    },
    {
      "path": [1, 0],
      "address": null,
      "url": null,
      "url_masked": false,
      "url_truncated": false,
      "name": null,
      "displayed": false,
      "zero_size": true
    }
  ],
  "truncated": false
}
  • path — the frame’s position, outermost first: what a step’s frame names. Positions follow the order the browser created the frames, which is not always the order of the <iframe> tags in the markup.
  • address — host and path of the document the frame holds. Never its query or fragment; a long path segment is written …. null for a frame with no web document (an empty or inline one) or one whose address the browser reports as the page’s own, so cannot be shown as the frame’s. That includes a frame on the page’s own host and path whose query differs: address is null for it, and url is shown (below). Frames that share one address (a payment form’s hosted fields, one provider path each) keep it when the page’s own address is known and is not that address.
  • url — the same document’s full address, scheme://host[:port]/path?query, so frames that share an address can be told apart. Never the fragment (the part after #) or a user:password@ part. The value of every credential-style query parameter (client_secret, token, password, access_token, api_key and the like) is replaced with REDACTED; the parameter names, their order and every other value are kept as the browser reports them. null where address is null, with one exception: a frame on the same host and path as the top page whose url differs from the page’s (another query) has address: null and its url shown, since a different query is a different document. A frame whose url is the page’s own, after masking, shows neither. At most 1000 characters.
  • url_masked — true when at least one value in url reads REDACTED.
  • url_truncated — true when url was longer than 1000 characters and was cut; it then ends ….
  • name — the frame’s name (its window.name, which the frame’s own page sets), on one line and cut to 60 characters (a longer one ends …), or null.
  • displayed, zero_size — whether it is shown, and whether it has no size.
  • truncated — the browser stopped listing before the page’s last frame. A list that holds at least one frame is a real, partial list; a browser that stopped before it could list any is no list at all — that is the 409 below, never "frames": [].

Telling hosted fields apart. A payment form often embeds one frame per field from the same provider path, and only the query says which is which. Their address is the same; their url is not:

{
  "frames": [
    {
      "path": [0],
      "address": "pay.example/elements-inner-card",
      "url": "https://pay.example/elements-inner-card?componentName=cardNumber",
      "url_masked": false,
      "url_truncated": false,
      "name": null,
      "displayed": true,
      "zero_size": false
    },
    {
      "path": [1],
      "address": "pay.example/elements-inner-card",
      "url": "https://pay.example/elements-inner-card?componentName=cardExpiry",
      "url_masked": false,
      "url_truncated": false,
      "name": null,
      "displayed": true,
      "zero_size": false
    }
  ],
  "truncated": false
}

Take a piece of the query that only one frame has and name the frame by it in a step’s frame (naming a frame by its address). {"src": "…"} matches against the frame’s full address, query included, so this keeps working when the page adds or removes other frames:

{
  "steps": [
    {
      "kind": "wait",
      "condition": "selector_visible",
      "selector": "input[name=\"exp-date\"]",
      "frame": [{ "src": "componentName=cardExpiry" }]
    },
    {
      "kind": "interact",
      "action": "type",
      "selector": "input[name=\"exp-date\"]",
      "value": "12 / 29",
      "frame": [{ "src": "componentName=cardExpiry" }]
    }
  ]
}

Choose text the browser sees as written: not a REDACTED value (the browser holds the real one, so it never matches) and nothing after a #. Driftstack returns url to you and does not store it.

Frames on the page’s own path. A page can embed frames from its own host and path that differ only in the query, for example https://shop.example.com/checkout?step=card inside https://shop.example.com/checkout. Their address is null, because it would read the same as the page’s, but their url is shown:

{
  "path": [0],
  "address": null,
  "url": "https://shop.example.com/checkout?step=card",
  "url_masked": false,
  "url_truncated": false,
  "name": null,
  "displayed": true,
  "zero_size": false
}

Copy a {src} frame level from url for such a frame, as above — for this one, {"src": "step=card"}. There is no address to copy from.

Right after a page load the list may be unavailable for a few seconds: a frame still loading can stall the browser’s walk of the page before it has listed anything. The session already waits about a second and a half and asks once more on a session that went active in the last 15 seconds (or that you asked within the last 5), so the first call after activation usually gets the real list; when it still cannot, the 409 below says retryable: true — call again shortly, and read a 409 as “not yet”, never as a page with no frames.

The numbering is the page’s at that moment: a page that loads or removes a frame renumbers the frames after it, so list again after a step that changes the page. POST /steps takes its own list and checks every path against it, so a stale path is a failed row, never a step sent to another frame.

A key with read:sessions, or the session’s control key; another account’s session is a 404. It counts against the agent_sessions:live rate limit. A 409 says why there is no list: reason: "session_not_running" (the session has ended), "frames_unsupported" (its browser cannot list frames; said only once the browser can be asked) or "frames_not_listed" (the list could not be had just now — including the walk that stalled before it listed any frame, above, and a browser that cannot be reached or has not yet said what it can do after reconnecting; retryable: true) — or code: "session_not_ready" while the session’s browser is starting.

The SDKs: agentSessions.listFrames(id) in TypeScript, agent_sessions.list_frames(id) in Python (sync and async) and AgentSessions.ListFrames(ctx, id) in Go.

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.

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 session is not active (closed or paused) — only active sessions can mint a token
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. 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 only the marker omits the value. 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. summary, reason and matchedText are 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 — whether or not the step is marked sensitive, so a marked press names its key in the summary even though its value is omitted.
  • 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.

Resume a challenge-paused session

POST /v1/agent-sessions/{id}/resume

When the session detects a bot-challenge (DataDome / Arkose / PerimeterX / AWS-WAF / GeeTest / …) it auto-pauses the session and emits a session.challenge_detected webhook. After you resolve the challenge (e.g. in the live view), call this to resume the agent.

Body: { "challenge_id"?: "<id-from-the-event>" }

challenge_id (optional) correlates to the session.challenge_detected you are responding to — when present, Driftstack checks it against the active challenge (a stale id leaves the session paused); when absent, it is a manual override resume.

Response 202:

{ "status": "resume_requested", "session_id": "<id>" }

404 if the session is not found or not owned by your account; 409 if the session is in a terminal state (resume requires an active session). Not available on every deployment.

A session paused by a bot check still reads status: "active" — the pause is not reflected in status. Watch for the session.challenge_detected webhook instead.

When a check pauses the session

No CAPTCHA solver provider is offered yet, so today every check is handed to you to solve in the live view. When providers are offered, a solver you configure will be tried first, with the hand-off to you as the fallback. A code the site sends by email or text message is always yours to enter. The session pauses as soon as the check is detected on the page, and stays paused until it is resumed — by the resume call above, or the live view’s Resume button.

What a message sent to a paused session gets back: the step is refused, the turn ends, and notice says what is going on with notice_reason beside it (the same pair on the notice frame of the progress stream). A program branches on the word:

  1. challenge_solver_pending — not sent today, because no solver provider is offered yet. Once one is: the session reported a check (a session.challenge_detected event), your account has a solver configured, and the provider accepted it. Wait for the page to clear, then resume and send “continue”. If it does not clear, solve it in the live view.
  2. challenge_customer_handoff — the check needs a person. This is the default: with no solver configured, every check is handed to you. It is also the answer when the configured provider does not solve this kind of check, or could not be reached. Solve it in the live view, resume, then send “continue”.
  3. verification_email / verification_sms — the site sent a code to your inbox or your phone. Enter it in the live view, resume, then send “continue”. A solver is never asked for these.
  4. unexplained_pause — the session is paused and the page shows no check, or the reason could not be confirmed. Open the live view to see what the page is showing, resume, then send “continue”.

A solver is asked only for a check the session itself reported — a recorded session.challenge_detected event for that session — and only for a structural check (reCAPTCHA, hCaptcha, Turnstile, a Cloudflare page, Arkose, DataDome, AWS WAF, GeeTest). A pause explained from the page’s words alone is handed to you.

The solver setting — which provider, and its key — is changed in the dashboard by a signed-in person, never with an API key. No provider is offered yet, so the dashboard shows no solver setting today and every check is a hand-off. When providers are offered, the key is stored encrypted like every other account secret and is never shown again, and clearing the setting hands every check back to you.

The nine endpoints below operate on the live, running session (they are what the desktop app’s page overlay, network pane, Cookies drawer, back/forward buttons, Files pane, and download bar call). Reads need read:sessions (broad read or account_owner also work); writes gate on the broad write scope (see the scope note at the top of this page). Apart from the page-state poll, each returns a discriminated 200 body in every case — status is one of ok, unavailable (the session is not running, cannot be reached right now, or live control is not enabled on this deployment), timeout (the session did not reply in time), or error (the session reported a failure; reason says why) — so expected-inert states surface as data, not HTTP errors. The network log answers only ok or unavailable. A malformed body or query is a 400; an unknown or cross-account session id is a 404.

The cookie read and import, proxy change, history step, and both download endpoints share a per-account limit of 16 requests in flight at once. Past it, the response is status: "error" with a reason asking you to retry shortly.

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": "[email protected]", "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 any other status nothing was written.

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
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.

Audit log

Creating and closing an agent session are recorded in the account’s audit log (agent_session.created, agent_session.destroyed), which you read in the Driftstack dashboard. A reopened session is recorded as agent_session.created, with resumed_from naming the ended session. Each top-up is recorded as agent_session.budget.added, with the tokens added and the new totals.

Errors

Status Type When
400 validation-failed body fails schema (missing user_message, a user_message over 8,000 characters, etc.)
400 bad-request create’s proxy_id names an HTTP proxy
403 forbidden create with mode: ai on a plan without the AI agent (the free trial, Free, Personal)
403 forbidden a model that runs only on your own key, at create or on a message (requires_own_key: true, with model naming it)
403 forbidden a message on bundled billing when the account’s current plan no longer includes it (add your own key)
404 not-found session id you cannot access (not your own, and not a team you hold admin on)
409 conflict mode mismatch, or ai_control_unavailable: true when control of the session changes while a message is running; the latter includes phase and can include consumed tokens_consumed, usage, and redacted partial_results that must not be replayed automatically
409 conflict turn_in_progress: true — another message is still running on this session; wait for it or stop it
409 conflict session_status — the session was not active ("closed", with closed_reason beside it when the session records one and closed_reason_detail, the same reason as a sentence, or "paused"); start a new session, optionally with continue_from_agent_session_id, or resume the paused one
409 conflict idempotency_status: "in_progress" — the first request with this Idempotency-Key is still running (retry later with the same key); "mismatch" — the key was used for a different request (use a new key)
409 session-not-ready a message to a session whose browser is still starting (ready is still false), after waiting up to 45 seconds (20 seconds on a JSON response) for it; nothing ran — send the same message again after retry_after_seconds (carries code: "session_not_ready", retryable: true)
409 profile-in-use create’s profile_id already has a live session (carries active_session_id)
409 device-unavailable create’s profile_id is on a device that is not offered right now (carries archetype, and held_reason when there is one)
409 storage-quota-exceeded a profile-backed create when your plan’s profile storage is full
409 conflict reopen of a session still running (code: "session_not_ended"), or of one whose configuration cannot start a session today (code: "reopen_configuration_unavailable", with fields)
409 conflict top-up of a session that has ended (code: "session_not_running"), or past 10,000,000 tokens (code: "token_budget_cap_exceeded", with max_add_tokens)
410 session-destroyed reopen of a session that ended more than 7 days ago (code: "reopen_window_passed", reopen_window_days: 7); create a new session instead
402 bundled-llm-budget-exhausted bundled-LLM monthly cap reached
402 bundled-llm-consent-required no key of your own and no opt-in to bundled billing; on a plan without bundled billing opting in is refused, so add your own key instead
422 proxy-validation-failed create’s live proxy test failed (carries reason)
422 proxy-required create named no proxy_id; nothing was created (carries code: "proxy_required")
429 rate-limited the agent_sessions:message request rate for your plan (agent_sessions:steps for a list of steps), or your account already has as many AI turns running at once as it may — across your sessions, or on bundled billing (retry_after_seconds)
429 concurrency-limit at create, your account already has as many open agent sessions as your plan allows; on a message, with ai_tasks_in_flight: true, 3 AI tasks are already running (depending on how your account’s AI is billed, that limit answers this way or as rate-limited, above): nothing ran, wait and send the message again
429 tier-limit at create only: the free trial’s 2 hours of session time are used up (code: "trial_session_time_used"), or the profile or proxy is outside the ones your plan can launch (code: "over_plan_limit")
500 internal something went wrong on our side, except a refusal from Anthropic we do not recognise yet (for example a spend limit set in the Anthropic Console): if it repeats on your own key, check that account first; a refusal we recognise, of your own Anthropic key or of the account behind it, is the 502 below
502 byok-anthropic-required no Anthropic key could be found for the turn: none sent, none stored, and bundled billing is not available — or, with key_rejected: true, Anthropic refused yours, or the account behind it (no credit left, or the organization disabled), on the first planning call (key_source, key_rejected_reason)
503 feature-unavailable on a message, an Idempotency-Key was sent but this deployment cannot record one, so the turn did not run — the same key will fail the same way, and the same request without the header runs the turn (Idempotency); that answer has no code (with code: "ai_provider_unavailable", see the next row: keep the header and use a new key); on Stop, stop_unconfirmed: true means the stop could not be confirmed just now (try again)
503 feature-unavailable on a message, code: "ai_provider_unavailable" (retry_after_seconds; Retry-After except when streamed): the AI service is temporarily unavailable, so nothing ran; steps sent to /steps still work. A turn on your own key never gets it
503 maintenance on a create or a reopen, every machine that could run the session is in a booked maintenance window (code: "maintenance", retryable: true, until, retry_after_seconds and Retry-After); nothing was created — send the same request again after until, with the same Idempotency-Key if you sent one