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
resultsfirst. Every step the turn ran, and the one it stopped at, comes back in itsresults, with the step itself inresults[i].intent(the selector it acted on, the text it typed) and what happened: asummaryfor a step that succeeded, areasonfor one that failed. That is the record of what was done on the page;answeris 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:
indexon anextractcounts every element the selector matches in the whole page, in page order, from 0:{ "kind": "extract", "selector": "li.result h3", "index": 2 }reads the thirdh3thatli.result h3matches, 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 everylithat is the thirdlichild of its parent, so on a page with two lists it matches one element in each, and adivorh2between thelis is not counted. A class does not change what is counted:li.result:nth-of-type(3)matches anlithat is the thirdliamong its siblings and also has the classresult— 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
writescope; there is no agent-sessions-specific granular scope. One documented write takes a granular scope: replacing the profile’s notes needswrite:profiles, because it edits the profile and runs nothing. Regular session routes accept the granularwrite: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 — needread:sessions, which broadreadandaccount_owneralso satisfy. If you mint a narrow CI key, include the broadwritescope 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.selectoris the element the step acted on, exactly as it was sent. Use it to run the step again yourself withPOST /steps, or to name the element in your next message.results[i].intent.valueis the text atypestep 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 withsensitive: trueand novalue(the third step above), when the step is markedsensitive: 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 stepsensitive: trueyourself.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 areason, and usually adiagnosisto branch on; a step held for your approval iskind: "confirmation_required"and did not run. Both are described under Message. okisfalsewhen 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 showok: truewith that failure still inresults, so check eachresults[i].kind.ok: truedoes not by itself mean the task is finished: when it is not, the turn carriesnotice(a sentence) andnotice_reason(one word to branch on), for example"notice_reason": "step_limit"— send “continue” to carry on.intentsis every step the turn attempted, including any it planned and never ran, so it can be longer thanresults. Read outcomes fromresults.
What stays the same between releases
intent— inintentsand in eachresults[i].intent— is the replay contract. It has theAgentIntentshape of the OpenAPI document, its fields keep their meaning, and a step sent back toPOST /stepsexactly as it came is accepted and does the same thing. Build on it.summaryandreasonon a step, andanswer,answer_unavailable,noticeandclarifying_questionon 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 onkind,diagnosis.category,diagnosis.retryable,notice_reason,warning.kindand 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:
- Send the task as a message and check the turn:
kind: "plan-executed",ok: true, and everyresults[i]a success. - Take the steps from
results[i].intent, in order (orintents, which also lists any step that was not run). A typed value that was stored assensitive: truecomes back without itsvalue: put the value — or its{{credential:…}}placeholder — back in before you replay. So does the text typed into a prompt by adialogstep, which comes back as{{prompt text not stored}}. - Send them to
POST /v1/agent-sessions/{id}/stepson a session at the same starting page, at most eight per call. No model is called, and each step is done exactly as written. - Read the new
resultsthe same way. A step that read the page (extract) returns its text insummary(orvalue/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_idas a bare UUID (noacc_prefix), unlikeGET /v1/whoami, which emitsacc_<uuid>, and theGET /v1/sessions/:idresource which emits prefixedses_/acc_/ key_IDs. Customer code comparingagentSession.account_idagainstwhoami.account_idmust strip theacc_prefix from the latter first. (idanddriftstack_session_idare prefixed —agt_<uuid>andses_<uuid>. Onlyaccount_idis 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 whenmodeis 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 403forbiddentier 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 with429(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 inmanualmode, 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}/stepsand 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 afterreadyturnstrue. For anaisession 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-keyheader 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 (statusprovisioningoractive), 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—truewhen there were more than 100; pageGET /v1/agent-sessions?status=openfor the rest. A session paused by a bot check still readsactiveand holds a slot; no session readspausedtoday.- 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, broadreadoraccount_owner; a create needs onlywrite). 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_sessionsis your plan’s limit, not a count: it reads the same aslimit. If your limit was lowered while more sessions were open,open_session_idscan list more ids thancurrent_sessionssays.
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 }withmatched_text1 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 withapprovals,approval,approve,approve_actions,approved_actions,approve_consequential_action,approveConsequentialActionsorapprove_consequential_actions_listis refused with400(validation-failed), whoseissuesname the key:unknown field 'approvals'; did you mean approve_consequential_actions?. Any other unknown key is ignored, as before, and listed in thex-driftstack-unknown-fieldsresponse 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 returns409and 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 answers409withcode: "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:
-
resultsholds every step that ran, in order, each with the step itself asresults[i].intent. Read steps from there.intentsis 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 — sointentsis longer thanresultswhenever a plan was abandoned part-way, and the two arrays need not line up by index. -
An entry of
resultscarries 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. -
answeris the answer to what the message asked (“…and tell me the total”), read back from the page after the steps ran. It appears only onkind: "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. - its own words say so: it has a question mark (
-
answeris 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 presentanswerdoes not by itself mean the information was found. -
answer_unavailabletakes 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 withanswer, and a message that only asked for actions has neither. It is open text: show it, do not match on it. -
Neither
answernoranswer_unavailableis ever on aclarify,refuse,stoppedorlogged-manualresult. Both can also be missing from aplan-executedone 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,noticeor the last entry ofresultssays what happened instead.Before you assert anything about
answer, check thatkindis"plan-executed"and thatansweris present. An assertion on an absentanswerpasses without checking anything: “the answer does not mention an error” holds for aclarifyjust as it does for a correct answer. Treat a missinganswer, on a message you sent to get one, as a failed check, and readanswer_unavailable,notice,clarifying_questionorrefuse_reasonfor the reason. -
noticeis 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. Treatnoticeitself 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_reasonis that same ending in one word, present whenevernoticeis and never without it, so a program can branch without reading the sentence. (Arefusecarriesnotice_reasontoo, but only when the refusal was not the agent’s choice:session_unresponsive, the session stopped answering automated steps before anything was planned; orbudget_low, too little of the session’s token budget is left for the message — add the tokensrefuse_reasonnames, or start a new session. Thererefuse_reasonis the sentence.)notice_reasonWhat happened What to do step_limitThe task needs more steps than one message runs. Send “continue”. time_limitThe message was taking too long. Send “continue”. budget_lowToo little of the session’s token budget is left. Add tokens first ( POST …/budget), then send “continue”.no_progressThe page stopped changing and the next step would have repeated one that changed nothing. Ask a person; noticeasks what to try differently.repeated_stepThe 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_unavailableThe next steps could not be worked out just now. Send “continue” to try again. page_unreadableCould not read the page to plan the next step. Send “continue” to try again. session_unresponsiveThis 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. questionThe agent asked you something part-way; noticeis the question.Answer it as the next message. declinedThe 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_reasonWhat happened What to do challenge_solver_pendingA 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_handoffA 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_emailThe site sent a code by email. Enter the code in the live view, resume the session, then send “continue”. verification_smsThe site sent a code by text message. Enter the code in the live view, resume the session, then send “continue”. unexplained_pauseThe 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
noticewith nonotice_reasonat all, the way you treatquestion: shownoticeand let a person decide. -
okistruewhen the last planned steps ran without a failure and without stopping for approval. It does not by itself mean the task is finished — checknotice— and a turn that recovered from a failed step can showok: truewith that failure still inresults.okisfalsewhen a step failed (a tap that changed nothing on the page is a failed step, categoryno_effect, below), when a step is waiting for approval (confirmation_required, below), and whenevernotice_reasonisno_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 (categoryinvalid_requestwhen the step itself cannot be sent,session_errorwhen the session’s browser could not be reached), andokstaysfalseafter 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 ofresultscan then be a success.okis alsofalsewhen 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 noanswerwas 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 ofresultscan then be a success, and there is no failed step to find — readnotice, oranswer_unavailablewhen 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
clarifywhen nothing has run yet, or as thenotice(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 — isok: false, with that ending as itsnotice. 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 isok: falsewith anoticesaying 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’stoken_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 offtoken_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 totoken_budget_remainingand 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
typestep 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 newIdempotency-Keyand the text is typed a second time ([email protected]@example.com). To set a field, send atypestep with an emptyvaluefirst (it clears the field) and then the new text, or type the whole value once. A retry under the sameIdempotency-Keyreplays the first answer and types nothing. The agent is told to plan that clear itself when it replaces a field’s text; checkresultsfor acleared …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_reasonsays 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_reasonsays 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"andrefuse_reasonnames 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
matchedTextfrom the result intomatched_text: the result field is camelCase and the request field is snake_case. The SDKs rename it for you: pass{ category, matchedText }toapproveConsequentialActionsin TypeScript, the held step itself in Python, anddriftstack.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
clarifyquestion 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 openand 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 dataphase{ phase }—planning,starting_browser,executing,reading_pageoranswering; later rounds of a turn addsegmentandcauseplan{ total, intents, labels }— the steps about to run, with a short caption for each; later rounds addoffsetstep_start{ index, total, label }— a step is startingstep{ index, result }— a step finished;resulthas the same shape as an entry ofresultsanswer{ answer }— the read-back answernotice{ notice, notice_reason }— the same sentence the result carries, with the same one-word reason when it has oneresponse{ status, body }— the result; always the last event -
One terminal frame, always named
response. The stream ends withevent: responsewhosedata: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
200at the HTTP layer and reports the failure as thestatusfield of the terminal envelope. Branching on the response status alone will read every one of those as success; readstatusfrom the payload. -
Authentication, scope and request-rate failures are ordinary HTTP errors. A missing or invalid key (
401), a key without thewritescope (403) and the request-rate429are decided before any stream exists, so they come back as plain statuses with no stream. Every other outcome — including the429for too many turns running — arrives in theresponseframe. -
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 sameIdempotency-Key: it returns409 conflictwithidempotency_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-executedwith anotice; send “continue” to carry on from the current page. - Without the stream, one request has 60 seconds. A message sent without
Accept: text/event-streamis one plain HTTP request, and the gateway in front of the API ends any plain request after 60 seconds with504. 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 anIdempotency-Key, its response is stored under that key. For turns that may take longer, use the stream. If a plain request ends with that504, send the same request again with the sameIdempotency-Key: it returns409 conflictwithidempotency_status: "in_progress"while the turn runs, then replays the stored response (or a final409withcode: "response_not_kept"if it could not be kept). If it still answersin_progressseveral minutes later, read the session transcript before you use a new key. So send plain messages with anIdempotency-Key: without one, a resend is refused withturn_in_progresswhile 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 conflictwithturn_in_progress: true. - A session that is still starting. A message sent before the session is
readywaits for it, for up to 45 seconds on the streamed response (20 seconds withoutAccept: text/event-stream), and then runs. If the session is still starting after that, the message returns409 session-not-readywithretry_after_seconds: 5and aRetry-Afterheader carrying the same 5: “The session is still starting. Try again in a few seconds.” Nothing ran, so send the same message again (the sameIdempotency-Keyis 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: 5and aRetry-Afterheader carrying the same 5, or, depending on how your account’s AI is billed, with429 concurrency-limitandai_tasks_in_flight: true. No step ran, so theIdempotency-Keyis 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:messagerate-limit bucket per plan; lists of steps draw onagent_sessions:stepsand tab calls onagent_sessions:tabsinstead — 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 returns409 conflictwithsession_status: "closed". Carry on in a new session withcontinue_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. Watchsession.history.entries_lefton each answer: whilehistory.nearly_fullistruethe answer also carrieswarning: { "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 carrieswarning: { "code": "history_full", "message": "…" }besideclosed_reason.warning.codeis an open string: match the codes you know and showmessagefor any other. An answer replayed for anIdempotency-Keyis 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 anotice: 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 withclosed_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 inaiorpairmode, 30 minutes inmanualmode. 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 withidle_timeout_seconds. A session created inmanualmode 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’ssummaryandreasonare the stored step lines (the step and its site, no page text), a navigate step andinitial_urlkeep only the address’s site (https://shop.example.com/), atypestep carries no typed value (it is markedsensitive: trueorvalue_omitted: true), and a value read off the page is not repeated. Reuse a key only when you got no response, a gateway504(above), or409withidempotency_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 readsarchetype_source: "random"; a device that came from the profile is the profile’s; mode,model, token budget (token_budget_total) andstop_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:
409withcode: "session_not_ended"— the session is still running;410(typesession-destroyed,code: "reopen_window_passed",reopen_window_days: 7) — it ended more than 7 days ago; create a new session instead;409withcode: "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 (fieldsnames which);404— the id is not a session you can access (another account’s id gets the same404as 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
409withcode: "token_budget_cap_exceeded",token_budget_total(the total now) andmax_add_tokens(the most you can still add); nothing is added. - Only a session that has not ended (
statusprovisioning,activeorpaused) can be topped up. A session that has ended is409withcode: "session_not_running"(session_status, andclosed_reasonwhen 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_exceededand 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_totalwith aGETfirst). - A body with any other field, or an
add_tokensthat is not a whole number above 0 (or is over 10,000,000), is400and nothing is added. 404— the id is not a session you can access (another account’s id gets the same404as 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 thewritescope.
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
usageand 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
noticesays 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
scrollto the side.directionmay also beleftorright, 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 withthe 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.
amountinstead ofamount_px:one_screen(a little less than the screen’s height, or its width forleftandright, so some of what was showing stays in view),to_top(all the way up, only withdirection: "up") orto_bottom(all the way down, only withdirection: "down"), as in{ "kind": "scroll", "direction": "down", "amount": "to_bottom" }. A step naming bothamountandamount_px, anamountthat is not one of these three, or ato_toporto_bottomwith another direction is refused with a400namingsteps.<i>.amount, and nothing is sent. On any other account anamountthat is one of these words is a400namingsteps.<i>.amounttoo (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). Ato_topon a page already at the top (or ato_bottomat the bottom) succeeds withscrolled 0px — the page is already at the top. - A
waitfor the page address.condition: "url_matches"withurlwaits 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.urlis 1 to 4,096 characters (counted in UTF-16 units; an empty one would match at once), and the wait takes noselectorand noframe: each of those, aurlwith a space or a character outside printable ASCII, andurlon any other condition, is refused with a400naming the field, and nothing is sent. The address is read by the browser itself; nothing runs in the page.timeoutMsis 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 nourl, so a step list taken from a stored receipt and sent again names nourland is refused with a400on an account in the preview: send theurlagain with it, as for atypestep’s text. On any other account such a wait is a failed row, as it always was (whileSTEP_UNKNOWN_FIELD_REFUSALis on, itsurlis a400namingsteps.<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
reasonnames the path and points toGET /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
waitinside a frame waits for the frame itself first. A payment frame is often inserted a moment after the page loads, so awaitforselector_visiblewith aframewaits, within itstimeoutMs(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 fulltimeoutMs. 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 await, 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. Sonavigatefollowed 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 (awaitforselector_visiblewith a selector such asiframe[name="card"]and noframe), 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
scrollorpress(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 — anextractby selector or by attribute (there only the whole frame’s text can be read). -
An
extractby 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. Withpropertyit reads what a field inside the frame holds now — a card field’svalue, itsname,autocompleteorouterHTML— returned in the result’svalueand 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
extractwithbody: trueinside a frame that shows no words succeeds, and itssummarysays 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 oraria-labelis 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
extractby selector inside a frame whose element has no text (a field: an input holds no text of its own) succeeds, and itssummaryis “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}}, oralice:s3cret;- or one saved credential’s placeholder,
{{credential:<handle>}}, as the whole text, whose saved value isusername: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’sframenames. 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….nullfor 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:addressisnullfor it, andurlis 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 anaddresscan be told apart. Never the fragment (the part after#) or auser:password@part. The value of every credential-style query parameter (client_secret,token,password,access_token,api_keyand the like) is replaced withREDACTED; the parameter names, their order and every other value are kept as the browser reports them.nullwhereaddressisnull, with one exception: a frame on the same host and path as the top page whoseurldiffers from the page’s (another query) hasaddress: nulland itsurlshown, since a different query is a different document. A frame whoseurlis the page’s own, after masking, shows neither. At most 1000 characters.url_masked—truewhen at least one value inurlreadsREDACTED.url_truncated—truewhenurlwas longer than 1000 characters and was cut; it then ends….name— the frame’sname(itswindow.name, which the frame’s own page sets), on one line and cut to 60 characters (a longer one ends…), ornull.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 the409below, 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 streamcanPublish: false— Driftstack publishes the video; you are subscriber-onlycanPublishData: true— used to send input events to the session
Auto-populated on session-create. When live video is available on the deployment,
POST /v1/agent-sessionsreturns the samelivekitshape inline on the 201 response, so clients can connect immediately after create without the explicit round-trip to this endpoint. Deployments without live video omit the field; the explicit endpoint is the fallback.
The SDKs mint it for you and return the same five fields:
agentSessions.livekitToken(id) in TypeScript,
agent_sessions.livekit_token(id) in Python (sync and async), and
AgentSessions.LivekitToken(ctx, id) in Go.
Errors:
| Status | Type | When |
|---|---|---|
| 404 | not-found | session id unknown OR caller doesn’t own it (anti-enumeration) |
| 403 | forbidden | 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
navigatestep’surlis the address’s site (scheme://host[:port]/, e.g.https://shop.example.com/), the words in a selector and atext=locator are cut to…, a frame’ssrcornameis its site ora frame, and atypestep has no typedvalueand is markedsensitive: trueorvalue_omitted: true(only a saved credential’s{{credential:…}}placeholder, or an empty value for a clear, is kept); - an agent entry’s step lines name each step and its site, never page text or a typed value;
- the exact plan of a step waiting for approval is held in the server’s memory only, never stored, and does not survive a restart (see Confirming a purchase, payment or account deletion).
What that omission covers, exactly: the value field of an
interact intent, and only that field, on the step object itself —
for any action marked sensitive: true, and for a type step whose
selector names a sensitive field even when it is not marked. 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
typestep’s summary is written from its selector, never from its text — for exampletyped into #email. Atypestep with an emptyvaluesayscleared #email. - A
pressstep’s summary repeats the key it pressed — for examplepressed Enter— whether or not the step is markedsensitive, so a marked press names its key in the summary even though itsvalueis 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’svalueis omitted or the step types a saved credential: then they are the page’s own words for the control, the step’sselector, or the result’scategorywhen the step has no selector. - A failed step’s
reasoncan quote the session’s own error message. If that message repeats the text of atypestep whosevalueis omitted, the text is replaced by[hidden].
This is a different flag from the sensitive on
POST /v1/sessions/:id/interact
(sessions), which only shapes typing behaviour in
the session.
Event types emitted:
transcript.entry— fires for each transcript append. Theid:SSE field is the entry’s index: an absolute number that never changes, also after older entries were dropped from the session’s live history (seehistoryunder Resource shape). The newest entry is numbertranscript_length - 1. Thedata:field is JSON with{ index, entry }whereentryis an object withrole,body,at, and optionallyintents(entries can carry other fields too; ignore any you do not recognise):role— one of'user'(customer-supplied message),'agent'(the AI’s output: plan-executed, clarify, or refuse), or'operator'(manual-mode pass-through — the customer’s own UI/script logging directly without involving the AI).body— always human-readable text, never JSON. For user and operator turns it is what was supplied. For agent turns it is a prose rendering of the AI’s outcome:refused: <reason>,clarify: <question>, a newline-joined plan summary for plan-executed turns (which may end(plan halted on failure)), or the answer text for a transcript question. Do notJSON.parseit — the structured form of a plan-executed turn isintents?below, notbody.at— ISO 8601 timestamp.intents?— present only onrole: 'agent'+ plan-executed turns; carries the structured intent list that was executed (the recipes route gathers these intointent_logsnapshots — see the recipe docs). Sensitive type intents retain their selector, ordering, andsensitive: truemarker but omitvalue.
transcript.rolled_off— sent at most once, before the replay, when the replay reaches back into entries that were dropped from the session’s live history. It has noid:field, so it does not move yourLast-Event-ID. Thedata:field is JSON with{ entries_dropped, live_from_index, first_task_index }: how many entries were dropped, the number of the first entry the agent works from apart from the first task, and the number of the first task, which is kept (nullif it was dropped too). The dropped entries then follow as normaltranscript.entryframes under their own numbers, so you lose nothing; the event only says the agent no longer works from them.transcript.error— sent once, just before the server closes the stream itself. It has noid:field. Thedata:field is JSON with{ code, detail }:detailis one sentence, andcodesays why:stalled(the stream was not taking entries fast enough: no progress for 30 seconds while entries were waiting to be sent, or the history took more than 5 minutes to send),live_queue_full(new entries passed 4,000,000 bytes while the history was still being replayed),backlog(the stream’s unread bytes passed 4,000,000 bytes),access_ended(the key that opened the stream no longer has access to the session),server_error(an error on the server’s side) orshutdown(the server is restarting). Treat a code you do not know the same way: the set may grow. The stream then closes. Reconnect withLast-Event-IDset to the last index you received: nothing is lost or repeated. If the client does not read the rest within 30 seconds (10 seconds onshutdown, when the server stops), the connection is reset instead; resume the same way.
Resume semantics (RFC 6202 + EventSource spec):
- The client’s last received id is sent back as
Last-Event-ID: <n>header on reconnect. The server replays every transcript entry with index > n, then live-streams new appends. - The replay is exclusive (strictly greater than the supplied index) so a resumed subscriber doesn’t see duplicate events.
- A new browser
EventSourcecannot send the header, so the same index may instead be given as the?last_event_id=<n>query parameter, the way?ds_token=stands in for theAuthorizationheader. It is read exactly as the header is, and the header wins whenever it is sent with a value (even a value that is not a number, which replays from the start). Rolling out: it is read only where it has been switched on; elsewhere the parameter is ignored and a new stream replays from the start. Give only an index this session’s stream sent you: a larger number also holds back new entries up to that number.
Heartbeat: server sends a : stream open comment on connect, then a
: heartbeat <ISO-8601> comment about every 30 seconds. Each heartbeat
re-checks your key, and the stream closes if the key has lost access.
Browsers’ EventSource auto-reconnect on disconnect uses
Last-Event-ID for resume, so a transient network blip doesn’t
lose any transcript content as long as the customer’s auth
token is still valid.
From a server, use the SDK rather than an SSE client of your own:
agentSessions.transcript(id, opts?) in TypeScript (an async generator),
agent_sessions.transcript(id, last_event_id=…) in Python (an iterator, and an
async iterator on AsyncDriftstack), and
AgentSessions.Transcript(ctx, id, opts, fn) in Go (a callback that returns
false to stop). Each one replays the entries already recorded, then follows
new ones; each sends Last-Event-ID for you when you pass the last index you
saw; and leaving the loop closes the connection, which matters because of the
account-wide cap below.
The SDKs never reconnect by themselves, and one call may not return a long
session’s whole history: each SDK holds one stream response to 8 MiB, and a
long session’s replay can pass that. One call may stop with a transport error
(TransportError in TypeScript and Python, *TransportError in Go) when the
stream passes the 8 MiB limit, or when the server sends transcript.error and
closes the stream (the error message names the code). Call it again with
lastEventId (last_event_id, LastEventID) set to the last index you
received: nothing is lost or repeated.
Example (TypeScript browser):
const url = new URL(`/v1/agent-sessions/${id}/transcript`, 'https://api.driftstack.dev');
url.searchParams.set('ds_token', token);
let refusals = 0;
let lastIndex = -1;
function open() {
// A new EventSource cannot send Last-Event-ID: give the last index you
// received as ?last_event_id= instead (read where it is switched on; see
// Resume semantics above), so a reopened stream carries on from it.
if (lastIndex >= 0) url.searchParams.set('last_event_id', String(lastIndex));
const stream = new EventSource(url.toString());
stream.addEventListener('open', () => {
refusals = 0;
});
stream.addEventListener('transcript.entry', (ev) => {
const { index, entry } = JSON.parse(ev.data);
lastIndex = index;
console.log(`[${index}] ${entry.role}: ${entry.body}`);
});
stream.addEventListener('error', () => {
// A stream that dropped reconnects by itself with Last-Event-ID. A stream
// that was refused (a 429) is closed for good: open a new one after the
// wait.
// A browser cannot see the status: stop after a few refusals in a row
// (a revoked key or a deleted session is refused every time).
if (stream.readyState === EventSource.CLOSED && ++refusals <= 5) setTimeout(open, 5_000);
});
}
open();
Closing the EventSource on beforeunload is the customer’s
responsibility. There is no per-session subscriber cap, but there IS
an account-wide one: at most 10 concurrent transcript streams per
account. The eleventh is refused with 429 and a Retry-After of
30 seconds, so a dashboard that opens a stream per visible session
will start shedding them once it crosses ten — across all sessions,
not per session. Each subscriber also holds a long-lived
connection.
While a stream sends history it reads from the session’s ARCHIVE (the entries
that rolled off, when your Last-Event-ID is absent or not past them), it counts
against a second limit: at most 4 such replays per account at once (16 on
one server). A stream opened past it is refused with 429 and a Retry-After
of 5 seconds; open it again after that wait. A replay ends when its history is
written, so a refused stream is let in as soon as one of the replays in flight
ends (5 minutes at most once its history starts to go out). A reconnect that resumes near the end (its
Last-Event-ID is past the rolled-off entries, as after a server restart) is
not refused by this limit (unless the session rolls off again at that moment). A browser EventSource does not retry a refused
stream by itself (its readyState becomes CLOSED): reopen it, as the example
above does.
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:
challenge_solver_pending— not sent today, because no solver provider is offered yet. Once one is: the session reported a check (asession.challenge_detectedevent), 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.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”.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.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.
Read the cookie jar
GET /v1/agent-sessions/{id}/cookies
Pulls the running session’s full live cookie jar — including
httpOnly cookies — from the session’s browser.
Response (200), discriminated:
{
"cookies": [
{
"domain": "example.com",
"name": "session",
"value": "…",
"path": "/",
"expires": 1780000000000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"status": "ok"
}
domain, name, and value are always present; path, expires
(unix milliseconds; null or omitted for session cookies),
httpOnly, secure,
and sameSite (Strict | Lax | None) appear when the store reports
them. On unavailable / timeout / error, cookies is null.
The jar shape round-trips 1:1 into Import cookies
below — you can save the cookies array to a file and re-import it
into a later session.
Import cookies
POST /v1/agent-sessions/{id}/cookies/set
{ "cookies": [{ "domain": "example.com", "name": "session", "value": "…" }] }
The write-twin of the read above: relays a cookie jar (the exact
shape the read emits, 1 to 2000 cookies per request) into the
running session’s cookie store. Response (200) is the discriminated
{ "status": …, "reason"?: … } shape — ok means the write was
applied to every cookie that was not skipped; on 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.
tabIdis not supported yet. Devices step the current tab only, so sendingtabIdis rejected with a422rather than silently stepping a different tab than you asked for. To step a specific tab, activate it first.
The response (200) is the discriminated { "status": …, "reason"?: … }
shape:
status |
What happened |
|---|---|
ok |
The step was applied. |
unavailable |
The step was not sent: history steps are not available here, or the session is not running or cannot be reached. reason says which. |
timeout |
No answer arrived in time. The step may still be applied, so check the page before sending it again. |
error |
The step was not applied. reason says why, in a sentence you can show as it is. |
Four error answers are about the session rather than the step:
- This session stopped answering the steps sent to it. It had already
failed to answer one (an earlier step got no answer in time, or the
browser’s own check on the page got no answer), and a quick check sent just
before this step got no answer either, so the step was not sent. The
live view may still show the page.
reasonsays to end the session and launch a new one; sending the step again will not help. - This session was slow to answer. It did answer that quick check, but so
slowly that too little of the request’s time was left for the step, so the
step was not sent.
reasonsays to try again. - The browser was still busy with another action. A session runs one
action at a time, so this step waits while the AI agent’s current action,
or an earlier back or forward, is still running. When that does not finish
within half of the request’s time, the step was not sent.
reasonsays to try again once the other action has finished. - The browser needed a quick check, and no time was left for it. A
browser that had already failed to answer gets that quick check before
each step. When this step first waited for another action to finish, too
little of the request’s time was left for the check and the step, so the
step was not sent.
reasonsays to try again.
Tabs
POST /v1/agent-sessions/{id}/tabs
{ "action": "open", "url": "https://mail.example.com/" }
The other bodies: { "action": "switch", "tab_id": "t1" },
{ "action": "close", "tab_id": "t2" } and { "action": "list" }.
Opens a page in a new tab, brings another tab to the front, closes a tab, or lists the session’s tabs, as a person does in Safari on the iPhone. Every other call (a list of steps, a message, a history step) acts on the tab in front.
openloadsurl(anhttp://orhttps://address) in a new tab and brings that tab to the front. The tab you were on stays open behind it. An address a navigate step may not open is refused the same way (400, namingurl): this computer, a private network, a name only a private network answers (one with no dot, such ashttp://intranet/, one with an empty label, such ashttp://a..b/, one ending.lan,.corp,.svcor another private-use suffix, or anip.io-style name for a private address or for none), or a user name and password. Your first tab call names the tab the session was already showing. New tab ids look liket48213. A session holds at most 8 tabs.switchbrings that tab to the front (the tab already in front: nothing is sent,reloaded: false). A tab the browser kept in memory comes back as you left it. A tab the browser had to unload, which happens when three or more tabs are open, is loaded again from its address. A tab the session cannot bring back (it never showed a web address, or its address held a secret and the server restarted since) answers409 tab_not_reloadable; close it instead.closecloses that tab. When it is the tab in front, the tab to its right comes to the front first (the one to its left when it was the last). The only open tab cannot be closed.listchanges nothing.
On a session that runs a saved profile, all four actions work. The
session starts on the profile’s active tab (or on initial_url when you pass
one). The profile’s other saved tabs are not opened in the session and are
not listed here, so switch and close cannot reach them. When the profile
is saved at the end of the session, the tabs open in it are added to the
profile’s saved tabs: a tab saved by an earlier session stays saved, also when
this session closes the tab it started on, and a tab opened and closed again in
this session is not saved. Past 64 saved tabs, the tabs used longest ago are
dropped. If a person takes control in the desktop app, the app’s tab bar
decides instead (see below).
The response (200) when the call worked:
{
"ok": true,
"action": "open",
"tab_id": "t48213",
"active_tab_id": "t48213",
"tab_count": 2,
"tabs": [
{ "tab_id": "t48212", "url": "https://shop.example.com/", "title": "Shop", "active": false },
{ "tab_id": "t48213", "url": "https://mail.example.com/", "title": "", "active": true }
]
}
A tab call waits up to 30 seconds for the tab to be in front: the session
says so at once for a tab it kept in memory, and for a tab it loads, once
it has opened the tab’s address in it. When that has not happened yet, the answer
has "pending": true (with active_tab_id the tab being brought to the
front). Until it is done, a call that would send the session something (a
tab call, a list of steps, a message, a history step) answers
409 tab_switch_pending; list answers with "pending": true until then.
A background tab’s url and title are what they were when it was last in
front; titles are kept in memory only, so they read empty after the server
restarts.
Each tab call, list too, counts against its own agent_sessions:tabs rate
limit, with the same figures as the agent_sessions:steps limit
POST /v1/agent-sessions/{id}/steps draws on: a tab call runs no AI, so it
does not use up your messages, and tab calls and lists of steps do not use up
each other. Past the limit the call is
refused with 429 rate-limited and a Retry-After header, as a list of steps
is, and nothing is sent to the session (see Rate limits).
A 200 with "ok": false means the call reached the session:
error |
What happened |
|---|---|
device_error |
The session refused before changing anything, or (closing the tab in front) moved to the next tab but could not close this one; reason says which. tabs is the list as it is now. |
page_load_failed |
The page could not be loaded in that tab, so the session went back to the tab it was on. The tab list is no longer known (see below). |
switch_failed |
The session could not bring that tab to the front. The tab list is no longer known (see below). |
outcome_unknown |
It is not known whether the tab was opened, switched or closed (the session did not confirm it in time, or the answer could not be recorded). The tab list is no longer known (see below). |
Refusals (nothing was sent to the session):
| Status | code |
When |
|---|---|---|
| 400 | (validation) | The body is wrong: open needs a url and no tab_id; switch and close need a tab_id; list takes neither. An address on this computer, a private network, or with a user name and password is refused. |
| 404 | No such session for your key, or tabs are not on for your account. | |
| 409 | tab_unknown |
No open tab has that id. |
| 409 | tab_list_unknown |
The tab list is no longer known (see below). |
| 409 | last_tab |
The only open tab cannot be closed. |
| 409 | tab_not_reloadable |
The session cannot bring that tab back (see switch above); close it instead. |
| 409 | tab_switch_pending |
A tab is still being brought to the front. List the tabs to see when it is done. |
| 409 | tab_limit |
The session already has 8 tabs. |
| 409 | (turn_in_progress) |
A message or a list of steps is running on this session. Try again when it has finished. |
| 409 | session_busy |
Another action on the session did not finish in time. |
| 409 | relay_busy |
Too many requests for this account are waiting on sessions. Try again shortly. |
| 409 | session_unresponsive |
The session stopped answering its last action. |
| 409 | (ai_control_unavailable) |
A person has control of the session. |
| 503 | session_not_running, session_unreachable, tab_state_unavailable, tabs_unavailable |
The session is not running, cannot be reached, or its tab list could not be read. Nothing was sent. Try again. |
| 503 | front_tab_unknown |
A new tab list is needed and the session has not yet said which tab it shows. Nothing was sent. Send a step that loads a page, then try again. |
When the tab list is no longer known. After a person has had control of
the session, or after a tab call that failed or was not confirmed, the
session may have tabs the list does not know. The next tab call starts a new list from the
tab in front (the answer says "list_restarted": true), and switch or
close of a tab from the old list answers 409 tab_list_unknown (or
tab_unknown once a call has restarted the list). The next open sends the
new list to the session, and the session closes its other tabs then.
- If a person takes control in the desktop app, the desktop app’s own tab bar replaces the session’s tabs: tabs opened through this route may close.
- While two or more tabs are open, a list of steps’
navigatedoes not report the page’s HTTP status or a load failure. - A tab that a page opens itself (a link with
target="_blank",window.open) is not in the list. - On a session that runs a profile, the open tabs are saved with the profile when the session ends (see above). The desktop app shows the saved tabs as tabs when it opens the profile; an API session starts on the profile’s active tab only.
Upload a file
POST /v1/agent-sessions/{id}/files
{ "name": "invoice.pdf", "mime": "application/pdf", "dataB64": "<base64 bytes>" }
Uploads a file into the running session’s isolated upload area
and returns a handle for it. The decoded
size is capped at 64 MiB per file (larger, or an empty file, is
a 400); per-account concurrent upload volume (512 MiB) and
per-session lifetime totals (2 GiB) are also capped — an over-cap
request returns status: "error" with the cap named in reason.
Each session takes a file up to its own size, so the largest file a session
accepts is usually smaller than 64 MiB:
about 2.9 MiB unless the session takes more. The session read’s upload_max_file_bytes gives the exact figure
for the session; check a file against it before reading it. A larger
file is refused with a 413 and nothing is sent: detail says “This file is
too large for this session (limit …)”, and the limit_bytes (the same
figure as upload_max_file_bytes) and size_bytes extensions carry the two
sizes. A request body over 96 MiB is also a 413.
| Status | Type | When |
|---|---|---|
| 400 | bad-request | The file is empty, or its decoded size is over 64 MiB. |
| 413 | payload-too-large | The file is larger than the session takes, or the body is over 96 MiB. Nothing was sent. |
Response (200), discriminated:
{
"handle": { "id": "<opaque>", "name": "invoice.pdf", "mime": "application/pdf", "size": 182044 },
"status": "ok"
}
handle is an opaque reference — file paths on the Mac running the
session are never exposed. On any non-ok status, handle is null.
On status: "error", code names two failures a program can act on, and
reason says the same in a sentence. Nothing was stored in either case:
upload_origin_changed— the session’s page moved to another site after the upload started, so the file was not kept. Upload it again on the page that needs it.upload_no_origin— the session’s page has no web address yet (its start page), so the upload cannot be tied to a site. Open the page you want to upload to first, then upload the file.
More codes may be added, so branch on the ones you know and show reason
otherwise. Any other failure has no code; read reason.
{
"handle": null,
"status": "error",
"reason": "Open the page you want to upload to first, then upload the file — nothing was stored.",
"code": "upload_no_origin"
}
To upload a file to a page: upload it here first, then tap the page’s
upload button (for example with an interact tap on the visible button).
The session’s browser gives the page the newest unused file uploaded on
the same web address as the page (scheme, host and port must match:
www.example.com and example.com count as different), once. A second
tap on a single-file input gets the next-older unused file; a file input
that takes several files gets every unused one. Upload the file while
the page that needs it is open, since an upload is tied to the web
address the session is on. There is no iPhone-style chooser yet, and the
handle id does not pick a file; the order of uploads does. Files you
upload are for this session only: they stay in the session’s upload area
on the Mac running it, are never copied to storage, and are deleted when
the session ends.
List downloads
GET /v1/agent-sessions/{id}/downloads
Lists files a page downloaded in the running session.
Downloads from pages are not available yet: the session’s browser
does not save a page’s downloads today, so this list stays empty
(files: []) and there is nothing to fetch. When downloads land, they
will go to a per-session isolated area on the Mac running the session,
never a shared folder. Response (200), discriminated:
{
"files": [{ "name": "report.csv", "size": 51234, "mime": "text/csv" }],
"status": "ok"
}
files: [] with status: "ok" means no downloads yet. name is
always a bare basename, never a path; mime appears when the session
reports one. On any non-ok status, files is null.
Fetch a download
GET /v1/agent-sessions/{id}/downloads/content?name=report.csv
Fetches one downloaded file’s bytes by name (a basename from the
list above; the server re-sanitizes it and confines the read to the
session’s download area). Fetches are capped at 64 MiB. Response
(200), discriminated:
{
"file": { "name": "report.csv", "mime": "text/csv", "dataB64": "<base64 bytes>" },
"status": "ok"
}
mime falls back to application/octet-stream when the session did
not report one. A missing or too-large file is status: "error"
with the cause in reason; on any non-ok status, file is
null.
Add format=binary to get the file’s raw bytes instead
(application/octet-stream, same 64 MiB cap) — simpler and smaller than
decoding dataB64. Outcomes other than success still come back as the small
JSON envelope above with file: null. Downloads can be listed and fetched only
while the session is open, so fetch them before you close it.
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 |