Drıftstack docs
Docs

Run AI tasks from your code

An agent session is an iPhone Safari browser in the cloud that an AI drives for you. You send a task in plain words (“open the invoices page and tell me this month’s total”); the agent plans the taps and page loads, runs them, and sends back what happened and the answer. This guide takes you from an API key to a job you can run every night, and answers the questions a program has to handle along the way. The complete programs at the end put it all together in TypeScript, Python, Go and curl.

What you’ll build

A nightly job that:

  1. starts an agent session on a saved profile — a stored browser identity, cookies and all — that is already signed in to a supplier portal,
  2. sends one task — “Open https://portal.example.com/invoices, find the invoice for September 2026 and tell me its total amount.”,
  3. prints each step as it finishes and then the answer, for example Answer: The September 2026 invoice total is €1,284.50.,
  4. answers the agent if it asks a question, and does not approve a payment on its own,
  5. stops the task if it runs too long, and always closes the session.

Before you start

  • A paid plan with the AI agent. Every paid plan on offer includes it — the tier values starter_v3, pro_v3, team_v3, scale_v3 and enterprise — and so do the earlier Team, Agency and API plans. On the free trial (trial) and on the earlier Personal plan (solo_manual), creating an AI session returns 403 forbidden.

  • An API key with read and write. Create one in the dashboard under API keys (app.driftstack.io/api-keys). The value is shown once; keep it as DRIFTSTACK_API_KEY. Creating, messaging, stopping and closing an AI session each need the broad write scope; the granular write:sessions is not enough. An OAuth token holds only granular scopes, so OAuth apps can’t run AI sessions today. Reads need read:sessions, which broad read covers. Keep account_owner off a job key: the job needs only a proxy’s id, which a job key cannot list (the proxy routes need account_owner), so look it up once with an owner key as below. See API key scopes.

  • Check the plan and the key before the first run. GET /v1/whoami returns the tier and the scopes:

    curl -sS https://api.driftstack.dev/v1/whoami \
      -H "Authorization: Bearer $DRIFTSTACK_API_KEY"
    # {"account_id":"acc_...","api_key_id":"key_...","tier":"pro_v3","scopes":["read","write"]}
  • An SDK, if you are not using curl: npm install @driftstack/sdk, pip install driftstack-sdk, or go get github.com/driftstackdev/driftstack-go@latest. See SDK installation.

  • SDK versions. The programs here are written against @driftstack/sdk 0.5.0, driftstack-sdk 0.5.0, or the Go module at v0.7.0. Earlier releases have no agent sessions at all up to 0.2.x (Go: up to v0.3.x), so they cannot run these examples.

One-time setup: a saved proxy and a signed-in profile

A profile keeps a browser identity — cookies and storage — between sessions. Sign it in to the portal once, then reuse it every night.

  1. Create the profile with POST /v1/profiles and keep its prof_… id.
  2. Sign in by hand: open a session on the profile in the desktop app and log in to the portal. Or, if you already have the portal’s cookies, import them into a running session with POST /v1/agent-sessions/{id}/cookies/set.
  3. Close that session. The profile’s cookies and storage are saved when the session ends.

Never put a password, a one-time code or a card number in a message. The message is kept word for word in the session’s history and is sent to the model. Sign in by hand once instead, or have the agent type the value from a saved credential or a session secret (a card number can only be a session secret): you write its placeholder ({{credential:cred_…}} or {{credential:sec_…}}) in the message, and the value is put into the step only as it is sent to the browser.

Every agent session goes out through one of your saved proxies. Save one once — with POST /v1/account/me/proxies using an owner key (account_owner scope), or in the desktop app’s Proxies tab, which saves it to your account the first time it tests the proxy or launches through it — and keep its id (GET /v1/account/me/proxies lists them, with the same owner key; see Find a proxy’s id). Pass it as proxy_id every time you create a session: a create without it is refused with 422 proxy-required, and nothing is created. The job’s own key does not need to list proxies; give the job the id, as you give it the profile.

Who pays for the AI

Every message the agent works on uses a Claude model. Starter, Pro, Team and Scale include AI credits each month. You can also use your own Anthropic key, and Driftstack does not charge for it. What each model costs in AI credits is on the AI credits rate card.

Way to pay Plans How it is paid
AI credits Starter, Pro, Team, Scale Your plan includes AI credits each month. Unused credits do not roll over.
Your own Anthropic key Every plan with the AI agent Anthropic bills your Anthropic account for the tokens used. Driftstack does not charge for the model.
Driftstack’s included AI (bundled LLM) Earlier API Builder and API Scale plans, Enterprise You opt in once. Each message the AI works on counts a flat $0.10 against a monthly budget you set (default $20, at most $100); Enterprise can use a contracted budget.

A key sent with a message is used for that message. On Starter, Pro, Team and Scale, a stored key is used as you choose on the Settings page: always, never, or automatically (the default), which uses your AI credits first and the stored key when the credits cannot pay for a message or for a model that runs only on your own key. On the earlier plans and Enterprise, a stored key takes the place of Driftstack’s included AI.

  • Store your key once on the dashboard’s Settings page. A stored key is used only while it is less than 90 days old — save it again to renew it.
  • Or send it with each message in the x-byok-anthropic-api-key header (byokApiKey / byok_api_key / ByokAPIKey in the SDKs). This needs only the job key’s write scope.
  • Or opt in to Driftstack’s included AI on the same Settings page, where you also set the monthly budget.

On Starter, Pro, Team and Scale, a message with no key runs on the plan’s AI credits, so the answers below do not come up there. On the earlier plans and Enterprise, this is what a message returns when neither is set up:

  • 402 bundled-llm-consent-required — your account has not opted in to Driftstack’s included AI. On a plan without it, opting in is refused, so add your own key instead.
  • 402 bundled-llm-budget-exhausted — this month’s included budget is used up (spent_cents, cap_cents). Raise the cap if it is below the $100 maximum, add your own key, or wait for the next calendar month.
  • 502 byok-anthropic-required — no key could be found for the message.
  • 403 forbidden — Driftstack’s included AI is switched on, but your current plan no longer includes it. Add your own key.

To check before a nightly run, read GET /v1/account/me/bundled-llm-status (consent, remaining_cents); it needs the key’s read scope. Whether a key is stored is shown on the dashboard’s Settings page. The usage block on each result shows what that message cost: on the included AI, usage.cost_usd_cents is the flat 10 cents. 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.

Pick a model

Leave model unset and the agent runs Claude Sonnet 5.5 (claude-sonnet-5-5). Other choices are listed in the create request. With your own key, Anthropic bills per token, so a smaller model such as claude-haiku-5-5 costs less for simple pages; on the included AI every message counts the same $0.10 whatever the model. On AI credits, a message is priced by the tokens it uses, so a smaller model uses fewer credits.

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) run only with your own Anthropic key. Without one, creating the session — or sending a message — returns 403 forbidden with requires_own_key: true and the refused model.

Start a session

POST /v1/agent-sessions with the profile, your saved proxy, and an Idempotency-Key so a retried create returns the same session instead of a second one. The SDKs retry a create that carries a key for you on network errors.

{ "mode": "ai", "profile_id": "prof_<uuid>", "proxy_id": "<proxy id>", "token_budget": 100000 }
  • mode defaults to ai, which is what a job wants. The other two, manual and pair, are for a session a person drives — or shares with the AI — from the desktop app. model defaults to Claude Sonnet 5.5.
  • token_budget is the most model tokens the session may use, set per session at create (at most 10,000,000). Left out, it is 500,000 on your own Anthropic key and 100,000 on Driftstack’s included AI. It is your cost ceiling for the session. Count about 25,000 tokens per turn on a complex page: a 10-page form wizard needs roughly 750,000. A running session can be given more with POST /v1/agent-sessions/{id}/budget ({ "add_tokens": 500000 }).
  • proxy_id is required: the saved proxy the session’s traffic goes out through. Add stop_on_exit_ip_change: true to end the session if that proxy’s exit IP changes mid-run.
  • 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 (an id from GET /v1/archetypes) to pin one, or profile_id to keep an identity between runs. The response’s archetype and archetype_source (random, explicit or profile) say what you got; the device never changes while the session runs.
  • Leave driftstack_session_id out; the agent session has its own browser.

The 201 response is the session. Its status reads active from the moment it exists, before its browser has finished starting, so read ready:

  • ready: true — the browser is ready; send the task.
  • ready: false — the browser is still starting. Poll GET /v1/agent-sessions/{id} every 2 seconds until ready is true. If it is still false after about two minutes, send the task anyway: the server no longer holds it then.
  • status: "closed" — the session could not start. Log closed_reason and stop.

A task sent before ready is true waits up to 45 seconds (20 seconds without Accept: text/event-stream), then is refused with 409 session-not-ready. Nothing ran: wait retry_after_seconds and send it again with the same Idempotency-Key.

What can go wrong at create:

  • 422 proxy-required — the create named no proxy_id. Nothing was created; create again with the id of one of your saved proxies.
  • 409 profile-in-use — the profile already has an open session (an earlier run that crashed, for example, or a person using the profile in the desktop app). The body’s active_session_id names it. Close it only if it is an id your program created; otherwise stop and tell the user which session holds the profile. An agt_… id is an agent session (DELETE /v1/agent-sessions/{id}); a ses_… id is an ordinary browser session started with /v1/sessions (DELETE /v1/sessions/{id}).
  • 429 concurrency-limit — your account already has as many open agent sessions as your plan allows (current_sessions and limit in the body); nothing was created. A session counts against that limit while its status is provisioning or active, so on a plan whose limit is 1, a session an earlier run left open makes this create fail. It is not a fault in the task: list your sessions with GET /v1/agent-sessions, close the ones your program created and no longer needs (any status but closed) with DELETE /v1/agent-sessions/{id}, and create again. The list also holds sessions a person runs by hand in the desktop app (manual): never close a session you did not create. Close every session in a finally block so the next run starts clean.
  • 422 proxy-validation-failed — the proxy did not pass its live test; the body’s reason says why. See account proxies.
  • 403 forbidden — the plan has no AI agent, or an Opus or Fable model without your own key (requires_own_key: true).

Send the task

POST /v1/agent-sessions/{id}/message with one user_message of up to 8,000 characters.

{
  "user_message": "Open https://portal.example.com/invoices, find the invoice for September 2026 and tell me its total amount."
}
  • Say what you want back. An answer comes back only on a plan-executed result, and only when your message asks for information — a question, or words such as tell me, find, check or what is — and the session still has enough of its token budget left to read the page. A message that only asks for actions gets the steps, and no answer.
  • Ask in plain words, not for a format. There is no JSON mode. Asking the agent to “reply with only JSON” does not change what comes back: answer, when there is one, is always a string written for a person to read.
  • Put the start URL in the message, so the agent knows where to begin.
  • Keep secrets out of the message. It is kept word for word in the session’s history and sent to the model. For a password, a one-time code or a card number, write a saved credential’s or a session secret’s placeholder instead, such as {{credential:sec_…}}; a card number can only be a session secret (see One-time setup).
  • Send a new Idempotency-Key for each new message. Reuse a key only to retry the same message after you lost the response (see Errors and safe retries).
  • Stream the response. Send Accept: text/event-stream (the SDKs do this for you) so the connection stays open while the agent works; see below.

How long a message takes

A message returns when the task is done or stops at a limit.

  • 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. A message makes a limited number of planning rounds; a task that needs more than that comes back with notice_reason: "step_limit", and sending “continue” carries on.
  • After about three minutes the agent starts no new steps and hands back with a notice asking you to send “continue”. A step that is already running always finishes, and the answer may still be read after that, so a message can take longer than three minutes.
  • The SDKs wait up to 50 minutes for a message. Keep that default rather than setting a short timeout: a message is never retried automatically, so timing out early only loses you the result.
  • The streamed response sends a keep-alive comment about every 15 seconds. With curl, pass -N and a generous --max-time.
  • Closing the connection does not cancel the task — it keeps running. To cancel, stop it. To get the result afterwards, send the same message again with the same Idempotency-Key.

Read what came back

Step results are the record. A turn that ran steps lists each one in results, with the step itself in results[i].intent — the selector it acted on, the value it typed (left out for a secret, such as a step marked sensitive: true or one typed into #password) — and what happened: a summary for a step that succeeded, such as tapped #sign-in: the button “Sign in”, or a reason for one that failed. Build your job on results; answer is a sentence for a person. The reference walks through a whole response.

Every result has a kind, and the session as it is now:

kind What happened Read
plan-executed The agent planned steps and ran them. results, ok, answer or answer_unavailable, notice + notice_reason
clarify The agent needs more information before it starts. clarifying_question — answer it in your next message
refuse The agent would not do the task, or the AI was briefly unavailable. refuse_reason
stopped You stopped the task. results (the steps that ran), notice
logged-manual Only in manual mode: your message was logged and nothing ran. Not used in this flow. —
  • Field names are mixed on purpose: the result’s own fields are snake_case (clarifying_question, refuse_reason, stopped_during), the fields inside a step are camelCase (captureId, matchedText). Copy them exactly.
  • A stopped result also carries stopped_during — planning, executing, reading_page or answering — saying how far it got.
  • A refuse is either the agent declining the task or the AI being briefly unavailable; refuse_reason says which. Either way the session stays active. One more refusal is neither: with notice_reason: "session_unresponsive" the session stopped answering automated steps before anything was planned (the live view may still show the page), so end the session and launch a new one. And with notice_reason: "budget_low" too little of the session’s token budget is left for the task: add the tokens refuse_reason names (POST /v1/agent-sessions/{id}/budget) and send it again, or start a new session. If it asks you to retry, send the task again under a new Idempotency-Key: this answer is stored against the old one. A refusal of the task itself will repeat, so change the task instead.

For plan-executed, decide in this order:

  1. notice is present — the task is not finished. The agent stopped at a limit — it ran out of time, or out of the planning rounds it may make for one message, or out of token budget; it was going in circles; or its next step 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 it asked you something part-way. The sentence says which, and what it needs from you. Most of them ask you to send “continue”, and the agent carries on from the current page; the 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. notice is open text: show it, do not match on it.

    Beside it, notice_reason says the same thing in one word, so an unattended job can decide what to do without reading English:

    notice_reason What happened What your program should 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; unattended, ask a person.
    declined The agent stopped rather than carry on. Ask a person. “continue” will not change the answer.

    Five more mean the session is paused because a verification check appeared, and nothing carries on until it is resumed — with resume() (the resume call) or the live view’s Resume button. The reference explains the hand-off in full under When a check pauses the session.

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

    The list is open: a turn may one day end a way that is not in it. Treat a value you do not recognise — and an older server that sends notice with no notice_reason — the way you treat question: show the sentence and alert a person rather than replying automatically.

  2. ok is false — a step failed or is waiting for your approval. That is usually the last entry of results, but not always: a step the agent planned and could not send, or a wait that did not come true, can be followed by steps that ran (a screenshot after it, say), so look through results for the failed step. After a failed step the page the turn ended on is still read back, so an answer can come with it. A turn that delivered nothing — no step that changed the page or scrolled it, no screenshot and no answer — is ok: false too, and so is one that did fewer of the things an English message asks for than it asks: every entry of results can then be a success, there is no failed step to find, and notice (step 1) or answer_unavailable says what happened.

  3. Otherwise the task is done. Read answer if you asked for one — or answer_unavailable, one sentence saying why there is none. The two never arrive together, and a task that only acts has neither. answer_unavailable is open text: show it, do not match on it.

Checking answer in a test or a monitor? First check that kind is "plan-executed" and that answer is present, then assert on its text. A clarify, a refuse, a turn that stopped to ask you something or is waiting for your approval, and a message that was not read as a question all come back with no answer — and an assertion on an absent answer passes without checking anything. Treat a missing answer as a failed check. A present one can still say the information was not on the page, so check what it says, not only that it is there. The message reference lists every case.

ok: true does not mean “finished”: it means the last planned steps ran without a failure. A message that recovered from a failed step can show ok: true with that failure still in results. It is never true beside notice_reason: "no_progress" — a turn that stopped because nothing was changing did not succeed — and a tap that changed nothing on the page is a failed step (diagnosis.category no_effect), not a successful one. A turn that delivered nothing is never reported as the task you asked for either: a plan that would end the turn having delivered nothing — no step that changed the page or scrolled it, no screenshot and no answer, unless you only asked to wait — or, for an English message that asks for things to be done, fewer of them than it asks, is sent back to the agent once, and then you are asked what to do instead — a clarify, or ok: false with notice_reason: "question" when steps already ran.

Each entry in results is one step that ran, in order, with the step itself as results[i].intent:

results[i].kind Meaning Read
success The step worked. summary; captureId for a screenshot
failure The step did not work. reason, diagnosis.category, diagnosis.retryable
confirmation_required The step would buy, pay or delete an account, so it was held for your approval. category, matchedText

A success can carry a warning. With warning.kind effect_unknown it is a tap that was made, where whether it changed anything on the page could not be checked: do not count on it until the page shows it. With warning.kind dialog_opened it is a tap that opened a JavaScript dialog on the page (warning.dialog says which, when known): answer the dialog with a dialog step rather than tapping again.

Read steps from results, not intents: intents is every step the turn attempted, across every plan it made, so it is longer than results whenever a plan was abandoned part-way. results[i].intent is the step each result is for.

On a failure, diagnosis is optional — check it is there before reading it. diagnosis.category is an open list (page_load_failed, element_not_found, element_covered and more over time; the reference has them all), so treat a value you do not recognise as unknown and decide on diagnosis.retryable. retryable: false means never repeat that step automatically — it may already have happened; look at the page first.

With warning.kind http_error_status, the site answered a navigation with an HTTP status of 400 or above (warning.status), and the summary says so. The step still worked — the page may ask to sign in or to complete a verification step, or be the whole page served under that status — so decide from the page. The list of kinds is open, like diagnosis.category.

Get a screenshot

The screenshot and download routes need read:sessions, which broad read covers, and work only while the session is open, so fetch before you close it.

A screenshot. A success step with a captureId has an image behind it. The SDKs fetch it for you — getCapture(id, captureId) in TypeScript, get_capture(id, capture_id) in Python, GetCapture(ctx, id, captureID) in Go — and hand back the bytes with the media type the server gave them:

for (const step of reply.results) {
  if (step.kind === 'success' && step.captureId !== undefined) {
    const shot = await client.agentSessions.getCapture(sessionId, step.captureId);
    await writeFile(shot.contentType === 'image/jpeg' ? 'step.jpg' : 'step.png', shot.bytes);
  }
}

By hand, it is one GET, and the body is the image itself — image/png or image/jpeg, see Content-Type — not JSON:

curl -sS "https://api.driftstack.dev/v1/agent-sessions/$ID/captures/$CAPTURE_ID" \
  -H "Authorization: Bearer $DRIFTSTACK_API_KEY" -o step.png

Only the 20 most recent captures of a session are kept, and each can be fetched for up to 30 minutes after it was taken, so fetch them as soon as the message returns. An unknown or expired captureId is a 404.

Files a page saves. Not available yet. The browser does not save page downloads today, so the downloads list below is always empty. That route has no SDK method yet — make it with your language’s own HTTP client and the same Authorization: Bearer header. When this is available, list the files, then fetch one by name:

curl -sS "https://api.driftstack.dev/v1/agent-sessions/$ID/downloads" \
  -H "Authorization: Bearer $DRIFTSTACK_API_KEY"
# {"files":[],"status":"ok"}

curl -sS "https://api.driftstack.dev/v1/agent-sessions/$ID/downloads/content?name=invoice-september.pdf&format=binary" \
  -H "Authorization: Bearer $DRIFTSTACK_API_KEY" -o invoice-september.pdf
  • $ID is the agent session’s id, $CAPTURE_ID a step’s captureId, and name the bare file name from the list — never a path.
  • Both download replies are discriminated on status, and only ok is a success: unavailable (the session is not running), timeout and error (reason says why) all arrive as 200 with files: null / file: null. Read status before you trust the bytes — with format=binary a failure still comes back as that small JSON envelope, so a plain -o can write the envelope into your file.
  • Leave format off and you get {"file": {"name", "mime", "dataB64"}, "status": "ok"} with the bytes base64-encoded instead. Fetches are capped at 64 MiB.

Full details: fetch a captured screenshot, list downloads, fetch a download.

When the agent stops to ask

The agent asks in three ways:

  • kind: "clarify" — it needs more information before it starts. Send the answer as your next message.

  • plan-executed with a notice — it asked something, or hit a limit, part-way through. notice_reason says which without reading the sentence: question means answer it as the next message; step_limit, time_limit, ai_unavailable and (once you have checked the page) repeated_step mean send “continue”; budget_low means add tokens (POST …/budget) first, then send “continue”; no_progress and declined mean a person should look. The full table is in Read what came back.

  • A confirmation_required step — the next step would make a purchase, a payment, or delete an account, so it was held and did not run. ok is false:

    {
      "kind": "confirmation_required",
      "intent": { "kind": "interact", "action": "tap", "selector": "#pay" },
      "category": "payment",
      "matchedText": "Pay now"
    }

To approve, send the same message again as the very next message on the session, with a new Idempotency-Key and the approval:

{
  "user_message": "Open https://portal.example.com/invoices, find the invoice for September 2026 and tell me its total amount.",
  "approve_consequential_actions": [{ "category": "payment", "matched_text": "Pay now" }]
}
  • The result says matchedText; the request field is matched_text. 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.
  • The agent carries on from the step it held; it does not plan the task again. On the included AI, an approval message does not count another $0.10.
  • If any other message comes in between, the approval no longer applies: the agent plans afresh and asks again.
  • In an unattended job, do not approve automatically. Alert a person, and close the session.

Stop a task that runs too long

message() waits until the task ends, so stop it from somewhere else — a timer, or a second worker — with POST /v1/agent-sessions/{id}/stop:

  • 202 {"status": "stop_requested"} — a task was running and has been asked to stop. Your pending message then returns kind: "stopped" with the steps that ran and a notice saying how far it got.
  • 200 {"status": "no_turn_running"} — nothing was running. If your own message is still waiting, ask again a second later.
  • 503 with stop_unconfirmed: true — the stop could not be confirmed and the task may still be running. Call it again. A 503 without that field means AI is not enabled on this deployment, and calling again will not help.

The SDKs hand you that status to branch on — stop(id) in TypeScript, stop(id) in Python (sync and async) and Stop(ctx, id) in Go all return the { status, session_id } body above, and raise the 503 as the typed feature-unavailable error with stop_unconfirmed among its extra fields (err.extensions / err.problem / err.Problem). None of them retries a stop for you, so the “call it again” above is yours to do.

Stopping is safe to repeat. A step that was already running is given a short time to finish so its result is known; nothing starts after it. The session stays open for the next message.

Two harder limits sit behind it: the session’s token_budget (when it runs out, the session closes), and DELETE /v1/agent-sessions/{id}, which ends the session outright.

Run steps you already know, without the AI

When your program already knows the steps — open this page, type that, take a screenshot — send them with POST /v1/agent-sessions/{id}/steps instead of a message. They run as written, in order, through the same checks a message’s steps do, with no AI planning and none of the session’s AI budget used. Up to eight steps per call; a step that cannot run is a failed row whose reason says why, and nothing after it is sent. The answer has the same plan-executed shape as a message’s, without usage or answer.

const run = await client.agentSessions.runSteps(session.id, [
  { kind: 'navigate', url: 'https://example.com/pricing' },
  { kind: 'capture', capture: 'screenshot' },
]);
run = client.agent_sessions.run_steps(
    session["id"],
    [
        {"kind": "navigate", "url": "https://example.com/pricing"},
        {"kind": "capture", "capture": "screenshot"},
    ],
)
run, err := client.AgentSessions.RunSteps(ctx, session.ID, []driftstack.AgentIntent{
	{Kind: "navigate", URL: "https://example.com/pricing"},
	{Kind: "capture", Capture: "screenshot"},
}, nil)

Typing adds to what the field holds. A type step adds its text to what the field already holds, so the same list sent again under a new Idempotency-Key types the text twice. To set a field, send a type step with an empty value first (it clears the field), then the new text — or type the whole value once.

To act on an element the agent can see, ask for its selector in a message (“What is the selector of the Pay button?”) and send a step with it here: { "kind": "interact", "action": "tap", "selector": "#pay" }. A step’s selector is used exactly as you wrote it, except that an id or a class that is no CSS name (such as one that starts with a digit, or carries a dotted number, as in #section-1.2) is sent as an attribute match (#1abc as [id="1abc"], .1x as [class~="1x"]); an escaped one that starts with a digit (.1\:x) is refused.

Inside an embedded frame — a card form or a sign-in box another website draws on the page — name the frame in the step’s frame. List the page’s frames first; each one’s path is what frame takes:

const { frames } = await client.agentSessions.listFrames(session.id);
const card = frames.find((f) => f.address?.startsWith('pay.example.net/'));
if (card) {
  await client.agentSessions.runSteps(session.id, [
    {
      kind: 'interact',
      action: 'type',
      selector: 'input[name="cardnumber"]',
      value: '{{credential:sec_4be1c09d2f7a4e6b8c35d1a0f9e27b64}}',
      frame: card.path,
    },
    { kind: 'interact', action: 'tap', selector: 'button[type="submit"]', frame: card.path },
  ]);
}
frames = client.agent_sessions.list_frames(session["id"])["frames"]
card = next(f for f in frames if (f["address"] or "").startswith("pay.example.net/"))
client.agent_sessions.run_steps(
    session["id"],
    [
        {"kind": "interact", "action": "type", "selector": 'input[name="cardnumber"]',
         "value": "{{credential:sec_4be1c09d2f7a4e6b8c35d1a0f9e27b64}}", "frame": card["path"]},
        {"kind": "interact", "action": "tap", "selector": 'button[type="submit"]', "frame": card["path"]},
    ],
)

The selector is matched inside that frame. A path the page does not have is a failed row naming it, and nothing is sent for it; a frame step after a step that may change the page is checked against a fresh list just before it is sent, so a navigate and the steps inside a frame on the new page can be one call. A card number or password goes in as a session secret whose websites include both the page’s and the frame’s. The full rules are in the reference.

This is also the way to drive a hosted session step by step: every hosted session runs through your proxy, and the step-by-step operations of /v1/sessions answer 503 on such a session.

It shares the session with messages: one runs at a time, a person in control gets a 409, and a step held for approval is approved by sending the rest of the list again with the approval. The full rules are in the reference.

Close the session

DELETE /v1/agent-sessions/{id} returns 204 and is safe to repeat. Close in a finally block (defer in Go), after you have fetched any screenshots and files:

  • it frees one of your open-session slots. A session you do not close keeps its slot until it ends on its own, so the next run’s create can be refused with 429 concurrency-limit because of it;
  • it saves the profile’s cookies and storage, so tomorrow’s run is still signed in;
  • it stops a turn that is still running, so closing to cut a task short does not leave you paying out an AI call you will never read. That message answers the same stopped result POST /{id}/stop gives.

A session can also end on its own — when its token budget runs out, when one call does not fit in its history even after older entries are dropped (see “Session history” below), when its proxy’s exit IP changes (stop_on_exit_ip_change), when it sits idle (about 5 minutes with no step running and no input), or when it has been open for about 12 hours, however busy it is. It then reads status: "closed" with a closed_reason, and a message to it returns 409 with session_status: "closed".

Errors and safe retries

The SDKs raise these as typed errors: err.extensions in TypeScript, err.problem in Python and err.Problem in Go carry the extra fields shown here. See the error reference for every class.

Status type When What to do
400 validation-failed The body is wrong, e.g. user_message over 8,000 characters. Fix the request.
401 unauthorized The API key is missing or invalid. Fix the key.
402 bundled-llm-consent-required No key of your own and no opt-in to the included AI. See Who pays for the AI.
402 bundled-llm-budget-exhausted This month’s included budget is used up. Raise the cap if it is below the $100 maximum, add your own key, or wait.
403 forbidden The key lacks write; the plan has no AI agent or no longer includes the included AI; or the session’s model runs only on your own key (requires_own_key, with model naming it). Fix the key, plan or model.
404 not-found The session does not exist or is not yours. Check the id.
409 conflict turn_in_progress: true — another message is still running on this session. Wait for it, or stop it.
409 conflict session_status: "closed" or "paused" — the session was not active, or ended mid-message; closed_reason says why it closed, and partial_results lists any steps that did run. Check partial_results, then resume a paused session or start a new one.
409 conflict idempotency_status: "in_progress" — the first attempt with this key is still running. Wait, then send again with the same key.
409 conflict idempotency_status: "mismatch" — this key was used for a different message. Use a new key.
409 conflict ai_control_unavailable: true — a person took control of the session from the desktop app while your message was running, so it stopped early. Read partial_results before repeating anything; send the task again with a new key once the AI has control.
409 session-not-ready The session’s browser has not finished starting (ready is still false). The message waited, then was refused; nothing ran. Wait retry_after_seconds (also in Retry-After), then send it again with the same key. Wait for ready: true before the first message.
429 rate-limited Too many requests, or your account already has the most AI messages it may run at once — across your sessions, or on the included AI. Wait retry_after_seconds (also in Retry-After), then send the same message again.
429 concurrency-limit At create: too many open sessions — often one an earlier run left open (current_sessions, limit). On a message with ai_tasks_in_flight: true: 3 AI tasks already running. Depending on how your account’s AI is billed, that limit answers here or as rate-limited, above. At create: list them with GET /v1/agent-sessions, close the ones your program created with DELETE /v1/agent-sessions/{id} (never a session you did not create: it may be a person’s), then create again. With ai_tasks_in_flight: true: wait for a task to finish, then send the message again.
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). A refusal we recognise, of your own Anthropic key or of the account behind it, is the 502 below. If it repeats on your own key, check the Anthropic account (credit, spend limits) first; try again later with a new Idempotency-Key, and contact support if it keeps happening.
502 byok-anthropic-required No Anthropic key could be found for the message — or, with key_rejected: true, Anthropic refused yours, or the account behind it, on the first planning call. Without key_rejected, see Who pays for the AI. With it, key_source says which key and key_rejected_reason whether to replace it (invalid_or_unauthorized), fix billing with Anthropic (billing), or resolve a disabled organization with Anthropic or use a key from another one (organization_disabled); a stored key can be tested on the dashboard’s Settings page.
503 feature-unavailable You sent an Idempotency-Key and it could not be recorded (no code), so the message did not run. Not transient: this deployment cannot record keys, so the same key fails the same way. The same message without the header runs the turn — do that only if running the task twice would be safe, since that protection is what you are giving up. See Idempotency.
503 feature-unavailable code: "ai_provider_unavailable": the AI service is temporarily unavailable, so the message did not run and no step ran. A turn on your own key never gets it. Wait retry_after_seconds (also in Retry-After unless streamed), then send the message again with a new Idempotency-Key: this one keeps the answer. Steps sent with POST /v1/agent-sessions/{id}/steps still work.

Retrying a message safely — which key to send:

  • The same key, after a refusal that did no work. Nothing ran, so the key is given back and the same request runs the turn once the cause is gone:

    Status type The refusal
    409 conflict turn_in_progress: true
    429 rate-limited the message rate, or too many AI messages running at once
    402 bundled-llm-consent-required not opted in to the included AI
    402 bundled-llm-budget-exhausted this month’s included-AI budget is used up
    403 forbidden the included AI is not on the plan, or the model needs your own key
    502 byok-anthropic-required no Anthropic key was available — without key_rejected: true

    The same holds for 429 concurrency-limit with ai_tasks_in_flight: true, the other answer of the 3-at-once limit (which one you get depends on how your account’s AI is billed): nothing ran, so wait and send the same request with the same key. Do not close sessions for it.

  • The same key, when you got no response (the connection dropped, your process restarted). You get the stored result, or 409 with idempotency_status: "in_progress" while the first attempt is still being resolved — also safe to retry with the same key.

  • A new key for everything else. Every other answer is final for its key and sending it again replays it: a finished turn, any failure after the turn started, 502 with key_rejected: true, 500, 503 with code: "ai_provider_unavailable", a 200 with kind: "refuse", and the 409 for a session that is closed or paused.

  • Check before you repeat work that changes things. After a 5xx, or a 409 that carries partial_results, some steps may already have run. Look at the page or the transcript before you send the task again.

  • The SDKs never retry message() for you, and a streamed message carries its errors inside the stream: the SDKs raise them as the same typed errors.

With curl, a missing or invalid key, a key without the write scope, and the request-rate 429 are ordinary HTTP errors. Every other outcome — including a bad body, an unknown session, and the 429 for too many AI messages running (either type) — arrives in the final response event with the status inside it, so read status from there.

Limits

Limit Value When you reach it
Open agent sessions Your plan’s concurrent-session number (session lifecycle) 429 concurrency-limit at create
Open sessions per profile 1 409 profile-in-use at create
Messages running at once, per session 1 409 conflict with turn_in_progress: true
AI messages running at once, per account 3 429 rate-limited (retry after 5 seconds), or 429 concurrency-limit with ai_tasks_in_flight: true, depending on how your account’s AI is billed; wait, then send it again
Included-AI messages running at once, per account 3 429 rate-limited (retry after 5 seconds), or 429 concurrency-limit with ai_tasks_in_flight: true, depending on how your account’s AI is billed; wait, then send it again
Message rate The agent_sessions:message bucket for your plan (rate limits) 429 rate-limited
Steps rate The agent_sessions:steps bucket for your plan (rate limits) 429 rate-limited
Tab calls rate The agent_sessions:tabs bucket, the same figures as the steps (rate limits) 429 rate-limited
Message length 8,000 characters 400 validation-failed
Model tokens per session token_budget (default 500,000 on your own key, 100,000 on the included AI) The session closes with closed_reason: "budget-exhausted"
Session history 256 entries or 1 MiB The oldest entries are dropped from the history the agent works from (the first task is kept) and the session goes on
New steps per message About three minutes of work plan-executed with a notice; send “continue”
Idle time About 5 minutes with no step running and no input The session closes with closed_reason: "idle_timeout"
Session age About 12 hours from create, however busy the session is The session closes with closed_reason: "orphaned-lifetime"; create a new one
Live transcript streams, per account 10 429, retry after 30 seconds

The history the agent works from keeps at most 256 entries (about 256 steps calls, or roughly 85 to 127 AI messages) and 1 MiB. When a call does 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 under their own numbers. history.entries_left on the session in each answer is the room before the next drop: a steps call needs 1 free entry and an AI message needs 3, and while fewer than 32 are left (or the history is close to 1 MiB) the answer carries warning with code: "history_nearly_full"; you need do nothing. history.entries_dropped counts the entries dropped so far, and transcript_length counts every entry ever written, dropped ones included. Only a single call 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 transcript-limit. A message normally adds two or three entries to the session history. To keep going after a session closes, create a new one with continue_from_agent_session_id set to the closed session’s id: it starts with your first message and the most recent part of that session’s history, up to half the limit (128 entries and 512 KiB), so there is room to keep going.

Watch it live (optional)

  • Step by step, from an SDK: pass onStep to message() to get each step as it finishes, and onEvent for the other progress events (on_step / on_event in Python, OnStep / OnEvent in Go). In TypeScript:

    const reply = await client.agentSessions.message(sessionId, task, {
      idempotencyKey: randomUUID(),
      onStep: ({ index, result }) => console.log(`step ${index + 1}: ${result.kind}`),
      onEvent: ({ type, data }) => {
        if (type === 'step_start') console.log('now:', (data as { label: string }).label);
      },
    });
  • In any language: the streamed response carries these events before the final one. Ignore event names you do not recognise — new ones are added over time — and treat response as the only result:

    event data
    phase { phase } — planning, starting_browser, executing, reading_page or answering
    plan { total, intents, labels } — the steps it is about to run
    step_start { index, total, label } — a step is starting, e.g. “Opening portal.example.com”
    step { index, result } — a step finished
    answer { answer }
    notice { notice, notice_reason }
    response { status, body } — the result; always last
  • The whole conversation: transcript(id) in TypeScript and Python, Transcript(ctx, id, opts, fn) in Go, reads every entry the session has written, oldest first (entries dropped from its live history included), and then follows it live over the transcript stream (GET /v1/agent-sessions/{id}/transcript). It does not end by itself while the session is open, so leave the loop when you have what you need — that closes the connection. To read only what is there now, take transcript_length from get(id) first and stop at index === transcript_length - 1:

    const { transcript_length } = await client.agentSessions.get(sessionId);
    if (transcript_length > 0) {
      for await (const { index, entry } of client.agentSessions.transcript(sessionId)) {
        console.log(index, entry.role, entry.body);
        if (index === transcript_length - 1) break;
      }
    }

    Pass the last index you saw back as lastEventId (last_event_id, LastEventID) to carry on where you left off. An account may hold 10 transcript streams open at once, and at most 4 of them may be sending a rolled-off history at the same moment (16 on one server); past either the call is refused with a 429 that says how long to wait. On a long session one call may stop before the end with a TransportError: each SDK holds one stream response to 8 MiB, and the server closes a stream whose reader fell behind (a transcript.error event). Call it again with lastEventId set to the last index you received: nothing is lost or repeated.

  • Watch the screen: see live video, or open the session in the desktop app.

Run it again tomorrow on the same profile

  • Create a new session each run with the same profile_id, and close it at the end. Only one session can be open on a profile at a time.
  • If a create returns 409 profile-in-use, another session holds the profile: an earlier run that did not close its session, or a person using the profile in the desktop app. active_session_id names it. Close it only if it is an id your program created; otherwise stop and tell the user which session holds the profile.
  • To clear up after a run that crashed, write each session id to a file when you create it (one file per program, never a shared one), and at the start of the job close only the ids in that file that are still open. GET /v1/agent-sessions (list() / iterate() in the SDKs) returns every session on the account, also the ones a person runs by hand, so never close a session because the list shows it. The list recipe has the code.
  • To notice that the portal signed the profile out, add to your task: “If you see a sign-in page, stop and tell me.” Then check answer and notice. Alert a person rather than sending a password in a message.

Complete examples

Each program starts a session on your profile, waits until it is ready, sends the task, answers one question if the agent asks, leaves a step the agent stopped on for a person to approve (or approves it when you set APPROVE_ACTIONS=yes), stops the task after ten minutes, prints the steps and the answer, and always closes the session.

The option names each SDK uses:

Option TypeScript Python Go
Idempotency key for a message idempotencyKey idempotency_key IdempotencyKey
Approve a held step approveConsequentialActions approve_consequential_actions ApproveConsequentialActions
Your own Anthropic key byokApiKey byok_api_key ByokAPIKey
Each step as it finishes onStep on_step OnStep
Other progress events onEvent on_event OnEvent

TypeScript

// run-invoice-task.ts — ask the AI to read this month's invoice total from a supplier portal.
// Run: DRIFTSTACK_API_KEY=ds_live_... PROFILE_ID=prof_... PROXY_ID=... npx tsx run-invoice-task.ts
import { randomUUID } from 'node:crypto';
import {
  Driftstack,
  ConflictError,
  ProfileInUseError,
  RateLimitError,
  TransportError,
  type AgentIntentResult,
  type AgentMessageResponse,
  type ConsequentialActionCategory,
} from '@driftstack/sdk';

const client = new Driftstack({ apiKey: process.env.DRIFTSTACK_API_KEY! });

const PROFILE_ID = process.env.PROFILE_ID!; // a profile you signed in to once, by hand
const PROXY_ID = process.env.PROXY_ID!; // the saved proxy every agent session goes out through
const TASK =
  'Open https://portal.example.com/invoices, find the invoice for September 2026 ' +
  'and tell me its total amount.';
// What the job answers if the agent asks a question part-way.
const REPLY_TO_QUESTIONS =
  'Use the invoice dated September 2026 and tell me its total. Do not pay anything.';
// Leave this off for an unattended job: a person should approve payments.
const APPROVE_ACTIONS = process.env.APPROVE_ACTIONS === 'yes';
const STOP_AFTER_MS = 10 * 60_000;

type Approval = { category: ConsequentialActionCategory; matchedText: string };

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// `status` reads `active` from the start; `ready` says when the browser can work.
// Still not ready after two minutes: send anyway, the server no longer holds the task.
async function waitUntilReady(id: string): Promise<void> {
  for (let i = 0; i < 60; i++) {
    const session = await client.agentSessions.get(id);
    if (session.status === 'closed') {
      throw new Error(`The session ended before it was ready: ${session.closed_reason}`);
    }
    if (session.ready !== false) return; // an older server leaves out `ready`: read it as ready
    await sleep(2_000);
  }
}

// One message = one Idempotency-Key. Retrying with the same key never runs the task twice.
async function send(
  id: string,
  text: string,
  approvals: Approval[] = [],
): Promise<AgentMessageResponse> {
  const idempotencyKey = randomUUID();
  // If the task runs too long, ask it to stop; this message then returns kind "stopped".
  const stopTimer = setTimeout(() => {
    client.agentSessions.stop(id).catch(() => undefined);
  }, STOP_AFTER_MS);
  try {
    for (let attempt = 1; ; attempt++) {
      try {
        return await client.agentSessions.message(id, text, {
          idempotencyKey,
          ...(approvals.length > 0 ? { approveConsequentialActions: approvals } : {}),
          onStep: ({ index, result }) => console.log(`  step ${index + 1}: ${describe(result)}`),
        });
      } catch (err) {
        // No response at all, or the first attempt is still running:
        // ask again with the SAME key. Any other error is final for this key.
        const stillRunning =
          err instanceof ConflictError && err.extensions['idempotency_status'] === 'in_progress';
        if ((err instanceof TransportError || stillRunning) && attempt < 6) {
          await sleep(5_000 * attempt);
          continue;
        }
        throw err;
      }
    }
  } finally {
    clearTimeout(stopTimer);
  }
}

function describe(result: AgentIntentResult): string {
  switch (result.kind) {
    case 'success':
      return result.captureId === undefined
        ? `done: ${result.summary}`
        : `done: ${result.summary} (screenshot ${result.captureId})`;
    case 'failure':
      return `failed: ${result.reason} (${result.diagnosis?.category ?? 'unknown'})`;
    case 'confirmation_required':
      return `waiting for approval: ${result.category} "${result.matchedText}"`;
  }
}

function report(reply: AgentMessageResponse): void {
  switch (reply.kind) {
    case 'plan-executed':
      if (reply.notice !== undefined) {
        // `notice_reason` is what an unattended job branches on; the sentence
        // is what a person reads. See "Read what came back".
        console.log(`Not finished (${reply.notice_reason ?? 'no reason given'}): ${reply.notice}`);
      } else if (!reply.ok)
        console.log('A step failed or is waiting for approval; see the steps above.');
      // `answer` and `answer_unavailable` never arrive together, and a task
      // that only acts has neither. Without the second branch a turn that ran
      // cleanly but could not read the page back prints nothing at all.
      if (reply.answer !== undefined) console.log(`Answer: ${reply.answer}`);
      else if (reply.answer_unavailable !== undefined)
        console.log(`No answer: ${reply.answer_unavailable}`);
      break;
    case 'clarify':
      console.log(`The agent still has a question: ${reply.clarifying_question}`);
      break;
    case 'refuse':
      console.log(`Refused: ${reply.refuse_reason}`);
      break;
    case 'stopped':
      console.log(`Stopped while ${reply.stopped_during}: ${reply.notice}`);
      break;
    default:
      console.log(`Unexpected result: ${reply.kind}`);
  }
}

async function main(): Promise<void> {
  const session = await client.agentSessions.create(
    { mode: 'ai', profile_id: PROFILE_ID, proxy_id: PROXY_ID, token_budget: 100_000 },
    { idempotencyKey: randomUUID() },
  );
  try {
    await waitUntilReady(session.id);
    let reply = await send(session.id, TASK);
    const approvals: Approval[] = [];
    for (let followUps = 0; followUps < 3; followUps++) {
      if (reply.kind === 'clarify') {
        console.log(`The agent asked: ${reply.clarifying_question}`);
        reply = await send(session.id, REPLY_TO_QUESTIONS);
        continue;
      }
      const last = reply.kind === 'plan-executed' ? reply.results.at(-1) : undefined;
      if (last?.kind === 'confirmation_required') {
        if (!APPROVE_ACTIONS) {
          console.log(`A person must approve: ${last.category} "${last.matchedText}"`);
          break;
        }
        // Approve by sending the same task again, as the very next message.
        approvals.push({ category: last.category, matchedText: last.matchedText });
        reply = await send(session.id, TASK, approvals);
        continue;
      }
      break;
    }
    report(reply);
  } finally {
    // Always close: it frees the slot and saves the profile's sign-in.
    await client.agentSessions.close(session.id);
  }
}

main().catch((err: unknown) => {
  if (err instanceof ProfileInUseError) {
    console.error(
      `The profile is in use by ${err.activeSessionId}; close it only if you started it.`,
    );
  } else if (err instanceof RateLimitError) {
    console.error(`Too many requests; try again in ${err.retryAfterSeconds}s.`);
  } else {
    console.error(err);
  }
  process.exitCode = 1;
});

Python

# run_invoice_task.py — ask the AI to read this month's invoice total from a supplier portal.
# Run: DRIFTSTACK_API_KEY=ds_live_... PROFILE_ID=prof_... PROXY_ID=... python run_invoice_task.py
import os
import threading
import time
import uuid
from typing import Any

from driftstack import ConflictError, Driftstack, ProfileInUseError, RateLimitError, TransportError

API_KEY = os.environ["DRIFTSTACK_API_KEY"]
PROFILE_ID = os.environ["PROFILE_ID"]  # a profile you signed in to once, by hand
PROXY_ID = os.environ["PROXY_ID"]  # the saved proxy every agent session goes out through
TASK = (
    "Open https://portal.example.com/invoices, find the invoice for September 2026 "
    "and tell me its total amount."
)
# What the job answers if the agent asks a question part-way.
REPLY_TO_QUESTIONS = (
    "Use the invoice dated September 2026 and tell me its total. Do not pay anything."
)
# Leave this off for an unattended job: a person should approve payments.
APPROVE_ACTIONS = os.environ.get("APPROVE_ACTIONS") == "yes"
STOP_AFTER_SECONDS = 10 * 60

client = Driftstack(api_key=API_KEY)


def wait_until_ready(session_id: str) -> None:
    """Wait for `ready`: `status` reads "active" from the start.

    Still not ready after two minutes: send anyway, the server no longer holds the task.
    """
    for _ in range(60):
        session = client.agent_sessions.get(session_id)
        if session["status"] == "closed":
            raise RuntimeError(f"The session ended before it was ready: {session['closed_reason']}")
        if session.get("ready") is not False:  # an older server leaves it out: ready
            return
        time.sleep(2)


def stop_turn(session_id: str) -> None:
    # Runs on a timer thread, so it uses a client of its own.
    with Driftstack(api_key=API_KEY) as timer_client:
        timer_client.agent_sessions.stop(session_id)


def describe(result: dict[str, Any]) -> str:
    if result["kind"] == "success":
        shot = f" (screenshot {result['captureId']})" if "captureId" in result else ""
        return f"done: {result['summary']}{shot}"
    if result["kind"] == "failure":
        category = (result.get("diagnosis") or {}).get("category", "unknown")
        return f"failed: {result['reason']} ({category})"
    if result["kind"] == "confirmation_required":
        return f'waiting for approval: {result["category"]} "{result["matchedText"]}"'
    return str(result["kind"])


def show_step(step: dict[str, Any]) -> None:
    print(f"  step {step['index'] + 1}: {describe(step['result'])}")


def send(
    session_id: str, text: str, approvals: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
    """One message = one Idempotency-Key. Retrying with the same key never runs the task twice."""
    idempotency_key = str(uuid.uuid4())
    # If the task runs too long, ask it to stop; this message then returns kind "stopped".
    stop_timer = threading.Timer(STOP_AFTER_SECONDS, stop_turn, args=(session_id,))
    stop_timer.start()
    try:
        for attempt in range(1, 7):
            try:
                return client.agent_sessions.message(
                    session_id,
                    text,
                    idempotency_key=idempotency_key,
                    approve_consequential_actions=approvals,
                    on_step=show_step,
                )
            except (TransportError, ConflictError) as err:
                # No response at all, or the first attempt is still running:
                # ask again with the SAME key. Any other error is final for this key.
                still_running = (
                    isinstance(err, ConflictError)
                    and err.problem.get("idempotency_status") == "in_progress"
                )
                if (isinstance(err, TransportError) or still_running) and attempt < 6:
                    time.sleep(5 * attempt)
                    continue
                raise
        raise RuntimeError("unreachable")
    finally:
        stop_timer.cancel()


def report(reply: dict[str, Any]) -> None:
    kind = reply["kind"]
    if kind == "plan-executed":
        if "notice" in reply:
            # `notice_reason` is what an unattended job branches on; the
            # sentence is what a person reads. See "Read what came back".
            reason = reply.get("notice_reason", "no reason given")
            print(f"Not finished ({reason}): {reply['notice']}")
        elif not reply["ok"]:
            print("A step failed or is waiting for approval; see the steps above.")
        # `answer` and `answer_unavailable` never arrive together, and a task
        # that only acts has neither. Without the second branch a turn that ran
        # cleanly but could not read the page back prints nothing at all.
        if "answer" in reply:
            print(f"Answer: {reply['answer']}")
        elif "answer_unavailable" in reply:
            print(f"No answer: {reply['answer_unavailable']}")
    elif kind == "clarify":
        print(f"The agent still has a question: {reply['clarifying_question']}")
    elif kind == "refuse":
        print(f"Refused: {reply['refuse_reason']}")
    elif kind == "stopped":
        print(f"Stopped while {reply['stopped_during']}: {reply['notice']}")
    else:
        print(f"Unexpected result: {kind}")


def main() -> None:
    session = client.agent_sessions.create(
        {"mode": "ai", "profile_id": PROFILE_ID, "proxy_id": PROXY_ID, "token_budget": 100_000},
        idempotency_key=str(uuid.uuid4()),
    )
    session_id = session["id"]
    try:
        wait_until_ready(session_id)
        reply = send(session_id, TASK)
        approvals: list[dict[str, Any]] = []
        for _ in range(3):
            if reply["kind"] == "clarify":
                print(f"The agent asked: {reply['clarifying_question']}")
                reply = send(session_id, REPLY_TO_QUESTIONS)
                continue
            results = reply.get("results") or []
            last = results[-1] if reply["kind"] == "plan-executed" and results else None
            if last is not None and last["kind"] == "confirmation_required":
                if not APPROVE_ACTIONS:
                    print(f'A person must approve: {last["category"]} "{last["matchedText"]}"')
                    break
                # Approve by sending the same task again, as the very next message.
                # Pass the held step back as it came: the SDK sends its matchedText
                # as the request's matched_text.
                approvals.append(last)
                reply = send(session_id, TASK, approvals)
                continue
            break
        report(reply)
    finally:
        # Always close: it frees the slot and saves the profile's sign-in.
        client.agent_sessions.close(session_id)


if __name__ == "__main__":
    try:
        main()
    except ProfileInUseError as err:
        raise SystemExit(
            f"The profile is in use by {err.active_session_id}; close it only if you started it."
        ) from err
    except RateLimitError as err:
        raise SystemExit(f"Too many requests; try again in {err.retry_after_seconds}s.") from err

Go

// run-invoice-task — ask the AI to read this month's invoice total from a supplier portal.
// Run: DRIFTSTACK_API_KEY=ds_live_... PROFILE_ID=prof_... PROXY_ID=... go run .
package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"log"
	"os"
	"time"

	driftstack "github.com/driftstackdev/driftstack-go"
)

const (
	task = "Open https://portal.example.com/invoices, find the invoice for September 2026 " +
		"and tell me its total amount."
	// What the job answers if the agent asks a question part-way.
	replyToQuestions = "Use the invoice dated September 2026 and tell me its total. Do not pay anything."
	stopAfter        = 10 * time.Minute
)

// Leave this off for an unattended job: a person should approve payments.
var approveActions = os.Getenv("APPROVE_ACTIONS") == "yes"

func newKey() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}

// waitUntilReady polls until the session's browser is ready. Status reads
// "active" from the start, so it reads IsReady. Still not ready after two
// minutes: send anyway, the server no longer holds the task.
func waitUntilReady(ctx context.Context, client *driftstack.Client, id string) error {
	for i := 0; i < 60; i++ {
		session, err := client.AgentSessions.Get(ctx, id)
		if err != nil {
			return err
		}
		if session.Status == "closed" {
			reason := ""
			if session.ClosedReason != nil {
				reason = *session.ClosedReason
			}
			return fmt.Errorf("the session ended before it was ready: %s", reason)
		}
		if session.IsReady() {
			return nil
		}
		time.Sleep(2 * time.Second)
	}
	return nil
}

func describe(r driftstack.AgentIntentResult) string {
	switch r.Kind {
	case "success":
		if r.CaptureID != "" {
			return fmt.Sprintf("done: %s (screenshot %s)", r.Summary, r.CaptureID)
		}
		return "done: " + r.Summary
	case "failure":
		category := "unknown"
		if r.Diagnosis != nil {
			category = r.Diagnosis.Category
		}
		return fmt.Sprintf("failed: %s (%s)", r.Reason, category)
	case "confirmation_required":
		return fmt.Sprintf("waiting for approval: %s %q", r.Category, r.MatchedText)
	default:
		return r.Kind
	}
}

// send runs one message. One message = one Idempotency-Key: retrying with the
// same key never runs the task twice.
func send(ctx context.Context, client *driftstack.Client, id, text string,
	approvals []driftstack.ConsequentialActionApproval) (*driftstack.AgentMessageResponse, error) {
	key := newKey()
	// If the task runs too long, ask it to stop; this message then returns kind "stopped".
	stopTimer := time.AfterFunc(stopAfter, func() {
		_, _ = client.AgentSessions.Stop(context.Background(), id)
	})
	defer stopTimer.Stop()
	for attempt := 1; ; attempt++ {
		reply, err := client.AgentSessions.Message(ctx, id, text, &driftstack.MessageOptions{
			IdempotencyKey:              key,
			ApproveConsequentialActions: approvals,
			OnStep: func(step driftstack.AgentStepEvent) {
				fmt.Printf("  step %d: %s\n", step.Index+1, describe(step.Result))
			},
		})
		if err == nil {
			return reply, nil
		}
		// No response at all, or the first attempt is still running:
		// ask again with the SAME key. Any other error is final for this key.
		var conflict *driftstack.ConflictError
		stillRunning := errors.As(err, &conflict) && conflict.Problem["idempotency_status"] == "in_progress"
		if (errors.Is(err, driftstack.ErrTransport) || stillRunning) && attempt < 6 {
			time.Sleep(time.Duration(5*attempt) * time.Second)
			continue
		}
		return nil, err
	}
}

func report(reply *driftstack.AgentMessageResponse) {
	switch reply.Kind {
	case "plan-executed":
		if reply.Notice != "" {
			// NoticeReason is what an unattended job switches on; the sentence
			// is what a person reads. See "Read what came back".
			reason := reply.NoticeReason
			if reason == "" {
				reason = "no reason given"
			}
			fmt.Printf("Not finished (%s): %s\n", reason, reply.Notice)
		} else if !reply.OK {
			fmt.Println("A step failed or is waiting for approval; see the steps above.")
		}
		// Answer and AnswerUnavailable never arrive together, and a task that
		// only acts has neither. Without the second branch a turn that ran
		// cleanly but could not read the page back prints nothing at all.
		if reply.Answer != "" {
			fmt.Println("Answer:", reply.Answer)
		} else if reply.AnswerUnavailable != "" {
			fmt.Println("No answer:", reply.AnswerUnavailable)
		}
	case "clarify":
		fmt.Println("The agent still has a question:", reply.ClarifyingQuestion)
	case "refuse":
		fmt.Println("Refused:", reply.RefuseReason)
	case "stopped":
		fmt.Printf("Stopped while %s: %s\n", reply.StoppedDuring, reply.Notice)
	default:
		fmt.Println("Unexpected result:", reply.Kind)
	}
}

func run(ctx context.Context, client *driftstack.Client) error {
	session, err := client.AgentSessions.Create(ctx, &driftstack.CreateAgentSessionRequest{
		Mode:        "ai",
		ProfileID:   os.Getenv("PROFILE_ID"), // a profile you signed in to once, by hand
		ProxyID:     os.Getenv("PROXY_ID"),   // the saved proxy every agent session goes out through
		TokenBudget: 100000,
	}, &driftstack.CreateOptions{IdempotencyKey: newKey()})
	if err != nil {
		var busy *driftstack.ProfileInUseError
		if errors.As(err, &busy) {
			return fmt.Errorf("the profile is in use by %s; close it only if you started it", busy.ActiveSessionID)
		}
		return err
	}
	// Always close: it frees the slot and saves the profile's sign-in.
	defer func() {
		if err := client.AgentSessions.Close(context.Background(), session.ID); err != nil {
			log.Printf("close failed: %v", err)
		}
	}()

	if err := waitUntilReady(ctx, client, session.ID); err != nil {
		return err
	}
	reply, err := send(ctx, client, session.ID, task, nil)
	if err != nil {
		return err
	}
	var approvals []driftstack.ConsequentialActionApproval
	for followUps := 0; followUps < 3; followUps++ {
		if reply.Kind == "clarify" {
			fmt.Println("The agent asked:", reply.ClarifyingQuestion)
			if reply, err = send(ctx, client, session.ID, replyToQuestions, nil); err != nil {
				return err
			}
			continue
		}
		steps, err := reply.ParsedResults()
		if err != nil {
			return err
		}
		if reply.Kind != "plan-executed" || len(steps) == 0 || steps[len(steps)-1].Kind != "confirmation_required" {
			break
		}
		last := steps[len(steps)-1]
		if !approveActions {
			fmt.Printf("A person must approve: %s %q\n", last.Category, last.MatchedText)
			break
		}
		// Approve by sending the same task again, as the very next message.
		approvals = append(approvals, driftstack.ApprovalFor(last))
		if reply, err = send(ctx, client, session.ID, task, approvals); err != nil {
			return err
		}
	}
	report(reply)
	return nil
}

func main() {
	client := driftstack.New(os.Getenv("DRIFTSTACK_API_KEY"))
	defer client.Close()
	if err := run(context.Background(), client); err != nil {
		var limited *driftstack.RateLimitError
		if errors.As(err, &limited) {
			log.Printf("too many requests; try again in %ds", limited.RetryAfterSeconds)
		}
		log.Print(err)
		os.Exit(1)
	}
}

curl

The same flow with raw HTTP, as a shell script. It needs jq, and uuidgen for the keys.

#!/usr/bin/env bash
# run-invoice-task.sh — ask the AI to read this month's invoice total from a supplier portal.
# Run: DRIFTSTACK_API_KEY=ds_live_... PROFILE_ID=prof_... PROXY_ID=... bash run-invoice-task.sh
: "${DRIFTSTACK_API_KEY:?set DRIFTSTACK_API_KEY to your API key}"
: "${PROFILE_ID:?set PROFILE_ID to the prof_… you signed in by hand}"
: "${PROXY_ID:?set PROXY_ID to the id of your saved proxy}"
API=https://api.driftstack.dev
AUTH="Authorization: Bearer $DRIFTSTACK_API_KEY"
TASK='Open https://portal.example.com/invoices, find the invoice for September 2026 and tell me its total amount.'

# 1. Start a session on the signed-in profile, through your saved proxy. Keep
#    CREATE_KEY to retry the create safely.
CREATE_KEY=$(uuidgen)
jq -n --arg p "$PROFILE_ID" --arg x "$PROXY_ID" \
  '{mode: "ai", profile_id: $p, proxy_id: $x, token_budget: 100000}' > create.json
curl -sS "$API/v1/agent-sessions" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $CREATE_KEY" --data @create.json > session.json
ID=$(jq -r .id session.json)
[ "$ID" != null ] || { cat session.json; exit 1; } # for example 409 profile-in-use or 422 proxy-required

# 2. Wait until its browser is ready. `status` reads `active` from the start, so
#    read `ready`. Still not ready after two minutes: send anyway.
for _ in $(seq 60); do
  curl -sS "$API/v1/agent-sessions/$ID" -H "$AUTH" > state.json
  [ "$(jq -r .id state.json)" = "$ID" ] || { cat state.json; exit 1; } # an error, such as 401 or 404
  [ "$(jq -r .status state.json)" = closed ] && { echo "The session ended before it was ready."; exit 1; }
  [ "$(jq -r .ready state.json)" = true ] && break
  sleep 2
done

# 3. send FILE — one message, streamed, under a new Idempotency-Key. The last event,
#    `response`, carries { status, body }; it is saved to result.json.
send() {
  TURN_KEY=$(uuidgen)
  curl -sS -N --max-time 3000 "$API/v1/agent-sessions/$ID/message" -H "$AUTH" \
    -H "Content-Type: application/json" -H "Accept: text/event-stream" \
    -H "Idempotency-Key: $TURN_KEY" --data @"$1" > turn.sse
  awk '/^event: response$/ { getline; sub(/^data: /, ""); print }' turn.sse > result.json
  # No `response` event: an ordinary HTTP error (bad key, missing scope, too
  # many requests) or a dropped connection. The body says which.
  [ -s result.json ] || { cat turn.sse; exit 1; }
  jq '{status, kind: .body.kind, ok: .body.ok, answer: .body.answer, notice: .body.notice,
       notice_reason: .body.notice_reason, question: .body.clarifying_question,
       steps: [.body.results[]? | .kind]}' result.json
}

jq -n --arg m "$TASK" '{user_message: $m}' > message.json
send message.json

# 4a. It asked a question (kind "clarify"): answer it as the next message.
if [ "$(jq -r .body.kind result.json)" = clarify ]; then
  jq -n '{user_message: "Use the invoice dated September 2026 and tell me its total. Do not pay anything."}' > reply.json
  send reply.json
fi

# 4b. A step is held for approval: approve it (only with APPROVE_ACTIONS=yes) by sending
#     the same task again, as the very next message, with the approval.
HELD=$(jq -r '.body.results[-1].kind // empty' result.json)
if [ "$HELD" = confirmation_required ] && [ "${APPROVE_ACTIONS:-}" = yes ]; then
  jq -n --arg m "$TASK" --slurpfile r result.json '{user_message: $m,
    approve_consequential_actions: [{category: $r[0].body.results[-1].category,
                                     matched_text: $r[0].body.results[-1].matchedText}]}' \
    > approve.json
  send approve.json
fi

# 5. To stop a task that runs too long, run this from another shell while `send` waits;
#    the waiting message then ends with kind "stopped":
#    curl -sS -X POST "$API/v1/agent-sessions/$ID/stop" -H "$AUTH" -H "Content-Type: application/json" -d '{}'

# 6. Always close the session: it frees the slot and saves the profile's sign-in. Prints 204.
curl -sS -X DELETE "$API/v1/agent-sessions/$ID" -H "$AUTH" -o /dev/null -w '%{http_code}\n'

If the curl inside send loses its connection, run it again with the same TURN_KEY: you get the stored result, or a 409 with idempotency_status: "in_progress" while the task is still running.