Drıftstack docs
Docs

Agent sessions: message

Part of the Agent sessions reference, which lists every call.

Message

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

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

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

Request body:

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

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

Headers:

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

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

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

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

Response (200) is a discriminated union by kind:

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

How to read a plan-executed turn:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    notice_reason What happened What to do
    challenge_solver_pending Not sent: Driftstack does not solve checks and no solver key can be saved. If you ever see it, treat it as a check that needs a person: solve it in the live view, resume, then send “continue”.
    challenge_customer_handoff A verification check needs a person. Solve it in the live view, resume the session, then send “continue”.
    verification_email The site sent a code by email. Enter the code in the live view, resume the session, then send “continue”.
    verification_sms The site sent a code by text message. Enter the code in the live view, resume the session, then send “continue”.
    unexplained_pause The session is paused, and no check is showing (or the reason could not be confirmed). Open the live view to see the page, resume the session, then send “continue”.

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

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

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

AI responses can include usage:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closed sessions return 409 Conflict; start a new one (optionally with continue_from_agent_session_id). A message or a list of steps sent to a closed session, and a turn that ends the session, get a 409 that carries session_status: "closed", closed_reason when the session records one, and closed_reason_detail: the same reason in plain words, ending with the next step when there is one (reopen the session, or start a new one). GET /{id} shows the same two fields. 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: a session with no budget left is closed at its next turn (closed_reason: "budget-exhausted", and the turn returns 409 conflict with session_status: "closed"); it may show active with 0 remaining until then.)

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

When a turn is refused

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

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

Because no step ran, sending the same message again cannot repeat anything on the page. It is a new turn, though, and is charged the same way: a message the Acceptable Use Policy refuses is refused every time, so do not resend that one. Send it with a new Idempotency-Key: the same key replays the refusal you already have. A request that needs a person’s decision (a purchase, a payment, an account deletion) is not meant to be refused for that reason: the turn runs up to that step and halts for your approval instead, unless the account’s purchase_check_mode lets that step through (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 account’s purchase_check_mode decides whether the turn halts before it (see When it stops below). When it halts, that step does not run, ok is false, and the last entry in results is:

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

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

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

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

When it stops

The account setting purchase_check_mode sets when the agent stops. Only the account owner changes it, in the dashboard under Settings → AI purchase check. In a team’s workspace that section shows the team owner’s choice, read-only, with the line “Only the account owner can change this.” It has three values:

  • always (Always ask) — the agent stops before every purchase, payment or account deletion it recognises.
  • smart (Smart: ask unless you asked for that exact one-off purchase with an amount) — the agent lets through only a one-off purchase or payment that you explicitly asked for, with the amount named in your own words. It stops for everything else. Deletions, subscriptions, plans, trials and anything that recurs always stop. A different currency, or a higher amount than you named, stops too. A step you send yourself (see Run a list of steps) always stops, because it carries no words of yours that say you asked for it.
  • never (Never ask) — the agent does not stop to ask before a purchase, payment or account deletion it recognises. One limit stays: when the pages in one task raise more of these steps than a task should need, the turn ends with a failed step and nothing further is sent.

A new account starts in smart. An account made before this setting existed keeps what it had: one that was asked before every purchase, payment or account deletion (the default until now) is in always, and one whose check was turned off is in never.

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

Streaming the turn (SSE)

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

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

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

    Event data
    phase { phase } — planning, starting_browser, executing, reading_page or answering; later rounds of a turn add segment and cause
    plan { total, intents, labels } — the steps about to run, with a short caption for each; later rounds add offset
    step_start { index, total, label } — a step is starting
    step { index, result } — a step finished; result has the same shape as an entry of results
    answer { answer } — the read-back answer
    challenge_detected { challenge_id, type, frame_index?, rect? } — only on an account with the challenge hand-off switched on (off by default): a verification check paused the turn’s first round of steps and the rest are kept for a “continue” (a check later in the turn sends only notice); sent just before notice
    challenge_resume { replayed, reason? } — the same accounts: the first event of a plain “continue” after such a pause; replayed: false with reason page_changed or page_unreadable when the turn plans again, or steps_gone when the kept steps are no longer held (for example after a restart)
    notice { notice, notice_reason } — the same sentence the result carries, with the same one-word reason when it has one
    response { status, body } — the result; always the last event
  • One terminal frame, always named response. The stream ends with event: response whose data: is JSON { status, body } — the HTTP status the JSON lane would have returned, and the same body. It is the only authoritative result.

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

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

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

  • A stream with no response frame lost its end, and the turn goes on. The server leaves the frame out only when your connection has already gone or more than 4 MB of the stream is waiting unread (64 KB when only heartbeats are going out). It does not cut a stream for how long it runs, and a turn that reaches its time limit still ends with the frame. A tool on your side can also cut long output: step frames carry the text a step read. Do not send the task again as a new message: read the transcript stream and the session’s state, or use your Idempotency-Key as above.

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 makes at most twelve planning calls of its own (Driftstack’s own corrections are not counted), and on most pages the three-minute limit ends it first. When a turn stops at one of these limits it returns plan-executed with a notice; send “continue” to carry on from the current page.

  • Without the stream, one request has 60 seconds. A message sent without Accept: text/event-stream is one plain HTTP request, and the gateway in front of the API ends any plain request after 60 seconds with 504. That body is the gateway’s, not the API’s answer (it may not be JSON), so do not read it as the turn’s result. On a session still starting, the wait for it counts against the same 60 seconds. The turn is not cancelled: it keeps running, its result goes into the session transcript, and, if the request carried an Idempotency-Key, its response is stored under that key. For turns that may take longer, use the stream. If a plain request ends with that 504, send the same request again with the same Idempotency-Key: it returns 409 conflict with idempotency_status: "in_progress" while the turn runs, then replays the stored response (or a final 409 with code: "response_not_kept" if it could not be kept). If it still answers in_progress several minutes later, read the session transcript before you use a new key. So send plain messages with an Idempotency-Key: without one, a resend is refused with turn_in_progress while the first turn runs and, once that turn has finished, runs the task again as a new message. Where the “still running” answer below is enabled, a turn that has started gets that 202 instead of the cut; a plain request whose turn has not started by then can still end with the 504.

  • “Still running” instead of a cut message (available when enabled for your account; not on for every account yet). Where it is enabled, a plain message whose turn has started and has not ended about 50 seconds after it reached the server is answered 202 with a JSON body kind: "still-running", code: "message_still_running" (and Retry-After: 10 when the message carried an Idempotency-Key), instead of being left for the gateway to cut at 60 seconds. The turn goes on: nothing was stopped, failed or refused. A message refused before its turn starts (another turn running, a limit, a closed session) keeps its own status and body. The body has session_id; elapsed_ms; transcript_length_at_start, the session’s transcript_length before the message; final_result; retry_after_seconds, only when final_result is same_idempotency_key; and detail, a sentence to show. It promises no end time: a turn’s answer is one more AI call after its last step. When final_result is same_idempotency_key, send the same request again with the same Idempotency-Key (and, like the first, without Accept: text/event-stream), at most once every retry_after_seconds: while the turn runs (and for a few seconds after) it answers the same 202 again, and then it returns the stored answer (or the final 409 with code: "response_not_kept", as above; each try counts against the agent_sessions:message rate limit). If that retry answers 409 with idempotency_status: "in_progress", the answer is not available (for example the server restarted; within about 30 minutes after the server restarted, the same key answers 409 with code: "response_not_kept" instead): read the session transcript, and do not send the message again under a new key. When final_result is transcript_only (no key was sent), the answer cannot be fetched by sending the message again: a resend is refused with turn_in_progress while the turn runs and, once it has ended, runs the task again as a new turn. Read the outcome in the transcript stream instead: GET /v1/agent-sessions/{id}/transcript is a Server-Sent Events stream, not a JSON answer (it needs a key with read:sessions), and the entries of this turn are numbered from transcript_length_at_start on (your message first, then the agent’s entries; a turn that ends normally ends with the agent’s reply, and some endings write none: the session was closed, a person took control, or an internal error). POST /{id}/stop cancels the turn. The event stream never gets this answer: for turns that may take longer than a minute, the stream is still the better choice.

    The 202 body of a message, field by field:

    Field Type What it says
    kind "still-running" Always this value.
    code "message_still_running" Always this value.
    session_id string The session the turn runs in.
    elapsed_ms integer Milliseconds since the message reached the server.
    transcript_length_at_start integer The session’s transcript_length before the message: this turn’s transcript entries are numbered from it on.
    final_result "same_idempotency_key" or "transcript_only" Where the outcome can be read: by sending the same request with the same Idempotency-Key, or (no key was sent) only in the transcript.
    retry_after_seconds integer, 1 or more Only when final_result is same_idempotency_key: the same number as the Retry-After header, the earliest a same-key retry is worth sending.
    detail string What to do next, as a sentence to show. Do not match on it.

    Poll every retry_after_seconds, never faster. Each same-key retry counts against the agent_sessions:message rate limit.

  • One turn at a time per session. A message sent while another is still running returns 409 conflict with turn_in_progress: true.

  • A session that is still starting. A message sent before the session is ready waits for it, for up to 45 seconds on the streamed response (20 seconds without Accept: text/event-stream), and then runs. If the session is still starting after that, the message returns 409 session-not-ready with retry_after_seconds: 5 and a Retry-After header carrying the same 5: “The session is still starting. Try again in a few seconds.” Nothing ran, so send the same message again (the same Idempotency-Key is fine).

  • Turns running at once, per account. At most 3 AI turns run at once across your sessions, and at most 3 of them on Driftstack’s included AI. Either ceiling refuses the next turn with 429 rate-limited, retry_after_seconds: 5 and a Retry-After header carrying the same 5, or, depending on how your account’s AI is billed, with 429 concurrency-limit and ai_tasks_in_flight: true. No step ran, so the Idempotency-Key is free: wait for a turn to finish, then send the same message again. The wait is a guess at how long a turn takes, not a promise — a turn is usually tens of seconds, so expect to wait again.

  • Message rate. Messages have their own agent_sessions:message rate-limit bucket per plan; lists of steps draw on agent_sessions:steps and tab calls on agent_sessions:tabs instead — see Rate limits.

  • History. The history the agent works from holds up to 256 entries or 1 MiB (a turn usually adds two or three entries). When the next message would not fit, the oldest entries are dropped from it and the session goes on: your first task and the newest entries are kept, and the transcript stream still returns the dropped entries (see Resource shape). Only a single message that needs more room than the history has after a drop, or a session that has already dropped the most it can (4,096 entries or 16 MiB), closes the session with closed_reason: "transcript-limit"; the message then returns 409 conflict with session_status: "closed". Carry on in a new session with continue_from_agent_session_id: it starts with your first message and the most recent part of the history, up to half the limit (128 entries and 512 KiB), so there is room to keep going. Watch session.history.entries_left on each answer: while history.nearly_full is true the answer also carries warning: { "code": "history_nearly_full", "message": "…" } (older entries will soon be dropped; you need do nothing), and the 409 a message or a list of steps gets from a session that closed this way carries warning: { "code": "history_full", "message": "…" } beside closed_reason. warning.code is an open string: match the codes you know and show message for any other. An answer replayed for an Idempotency-Key is the stored one, so its numbers are those of the original call.

  • Token budget. Each turn spends model tokens from the session’s token_budget. When too little is left to plan again, the turn ends with a notice: add tokens first, then send “continue” (a message sent before the top-up is refused). A session with no budget left is closed at its next turn (closed_reason: "budget-exhausted"); it may show active with 0 remaining until then.

  • How long a session stays open. A session with nothing happening in it is closed (closed_reason: "idle_timeout"): after about 5 minutes without a step or input in ai or pair mode, 30 minutes in manual mode. A session running a step, a paused session and one still starting are not counted as idle; an AI turn’s planning between two steps is. Reading the session (GET /v1/agent-sessions/{id}, the list) does not count as activity. A create can choose a shorter limit with idle_timeout_seconds. A session created in manual mode is closed at 4 hours (max_duration). Any session still open about 12 hours after it was created is closed (orphaned-lifetime), however busy it is. On the free trial a session is closed at 20 minutes.

  • Idempotency. With an Idempotency-Key, every response after the request is accepted — errors included — is stored for that key and replayed on a retry. A 202 still-running answer is not stored: the retry gets the turn’s own answer once it has ended. A replay is the STORED response: each step’s summary and reason are the stored step lines (the step and its site, no page text), a navigate step and initial_url keep only the address’s site (https://shop.example.com/), a type step carries no typed value (it is marked sensitive: true or value_omitted: true), and a value read off the page is not repeated. Reuse a key only when you got no response, a gateway 504 (above), a 202 still-running answer (above), or 409 with idempotency_status: "in_progress"; after any other response, send a new key once you have fixed the cause or waited.