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:
- starts an agent session on a saved profile — a stored browser identity, cookies and all — that is already signed in to a supplier portal,
- sends one task — “Open
https://portal.example.com/invoices, find the invoice for September 2026 and tell me its total amount.”, - prints each step as it finishes and then the answer, for example
Answer: The September 2026 invoice total is €1,284.50., - answers the agent if it asks a question, and does not approve a payment on its own,
- 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_v3andenterprise— 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 returns403 forbidden. -
An API key with
readandwrite. Create one in the dashboard under API keys (app.driftstack.io/api-keys). The value is shown once; keep it asDRIFTSTACK_API_KEY. Creating, messaging, stopping and closing an AI session each need the broadwritescope; the granularwrite:sessionsis not enough. An OAuth token holds only granular scopes, so OAuth apps can’t run AI sessions today. Reads needread:sessions, which broadreadcovers. Keepaccount_owneroff a job key: the job needs only a proxy’s id, which a job key cannot list (the proxy routes needaccount_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/whoamireturns thetierand 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, orgo get github.com/driftstackdev/driftstack-go@latest. See SDK installation. -
SDK versions. The programs here are written against
@driftstack/sdk0.5.0,driftstack-sdk0.5.0, or the Go module atv0.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.
- Create the profile with
POST /v1/profilesand keep itsprof_…id. - 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. - 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-keyheader (byokApiKey/byok_api_key/ByokAPIKeyin the SDKs). This needs only the job key’swritescope. - 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 }
modedefaults toai, which is what a job wants. The other two,manualandpair, are for a session a person drives — or shares with the AI — from the desktop app.modeldefaults to Claude Sonnet 5.5.token_budgetis 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 withPOST /v1/agent-sessions/{id}/budget({ "add_tokens": 500000 }).proxy_idis required: the saved proxy the session’s traffic goes out through. Addstop_on_exit_ip_change: trueto 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 fromGET /v1/archetypes) to pin one, orprofile_idto keep an identity between runs. The response’sarchetypeandarchetype_source(random,explicitorprofile) say what you got; the device never changes while the session runs. - Leave
driftstack_session_idout; 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. PollGET /v1/agent-sessions/{id}every 2 seconds untilreadyistrue. If it is stillfalseafter about two minutes, send the task anyway: the server no longer holds it then.status: "closed"— the session could not start. Logclosed_reasonand 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 noproxy_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’sactive_session_idnames it. Close it only if it is an id your program created; otherwise stop and tell the user which session holds the profile. Anagt_…id is an agent session (DELETE /v1/agent-sessions/{id}); ases_…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_sessionsandlimitin the body); nothing was created. A session counts against that limit while itsstatusisprovisioningoractive, 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 withGET /v1/agent-sessions, close the ones your program created and no longer needs (anystatusbutclosed) withDELETE /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 afinallyblock so the next run starts clean.422 proxy-validation-failed— the proxy did not pass its live test; the body’sreasonsays 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
answercomes back only on aplan-executedresult, 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 noanswer. - 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-Keyfor 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
noticeasking 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
-Nand 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
stoppedresult also carriesstopped_during—planning,executing,reading_pageoranswering— saying how far it got. - A
refuseis either the agent declining the task or the AI being briefly unavailable;refuse_reasonsays which. Either way the session stays active. One more refusal is neither: withnotice_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 withnotice_reason: "budget_low"too little of the session’s token budget is left for the task: add the tokensrefuse_reasonnames (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 newIdempotency-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:
-
noticeis 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.noticeis open text: show it, do not match on it.Beside it,
notice_reasonsays the same thing in one word, so an unattended job can decide what to do without reading English:notice_reasonWhat happened What your program should do step_limitThe task needs more steps than one message runs. Send “continue”. time_limitThe message was taking too long. Send “continue”. budget_lowToo little of the session’s token budget is left. Add tokens first ( POST …/budget), then send “continue”.no_progressThe page stopped changing and the next step would have repeated one that changed nothing. Ask a person; noticeasks what to try differently.repeated_stepThe next step would have repeated an action that already ran, which could do it twice. Check the page, then send “continue” only if it is safe. ai_unavailableThe next steps could not be worked out just now. Send “continue” to try again. page_unreadableCould not read the page to plan the next step. Send “continue” to try again. session_unresponsiveThis session stopped answering automated steps (the live view may still show the page), so nothing more was sent to it. End the session and launch a new one. “continue” will not help. questionThe agent asked you something part-way; noticeis the question.Answer it as the next message; unattended, ask a person. declinedThe 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_reasonWhat happened What your program should do challenge_solver_pendingA 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_handoffA 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_emailThe 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_smsThe 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_pauseThe 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
noticewith nonotice_reason— the way you treatquestion: show the sentence and alert a person rather than replying automatically. -
okisfalse— a step failed or is waiting for your approval. That is usually the last entry ofresults, 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 throughresultsfor the failed step. After a failed step the page the turn ended on is still read back, so ananswercan come with it. A turn that delivered nothing — no step that changed the page or scrolled it, no screenshot and noanswer— isok: falsetoo, and so is one that did fewer of the things an English message asks for than it asks: every entry ofresultscan then be a success, there is no failed step to find, andnotice(step 1) oranswer_unavailablesays what happened. -
Otherwise the task is done. Read
answerif you asked for one — oranswer_unavailable, one sentence saying why there is none. The two never arrive together, and a task that only acts has neither.answer_unavailableis 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
$IDis the agent session’sid,$CAPTURE_IDa step’scaptureId, andnamethe bare file name from the list — never a path.- Both download replies are discriminated on
status, and onlyokis a success:unavailable(the session is not running),timeoutanderror(reasonsays why) all arrive as200withfiles: null/file: null. Readstatusbefore you trust the bytes — withformat=binarya failure still comes back as that small JSON envelope, so a plain-ocan write the envelope into your file. - Leave
formatoff 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-executedwith anotice— it asked something, or hit a limit, part-way through.notice_reasonsays which without reading the sentence:questionmeans answer it as the next message;step_limit,time_limit,ai_unavailableand (once you have checked the page)repeated_stepmean send “continue”;budget_lowmeans add tokens (POST …/budget) first, then send “continue”;no_progressanddeclinedmean a person should look. The full table is in Read what came back. -
A
confirmation_requiredstep — the next step would make a purchase, a payment, or delete an account, so it was held and did not run.okisfalse:{ "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 ismatched_text. The SDKs rename it for you: pass{ category, matchedText }toapproveConsequentialActionsin TypeScript, the held step itself in Python, anddriftstack.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 returnskind: "stopped"with the steps that ran and anoticesaying how far it got.200 {"status": "no_turn_running"}— nothing was running. If your own message is still waiting, ask again a second later.503withstop_unconfirmed: true— the stop could not be confirmed and the task may still be running. Call it again. A503without 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-limitbecause 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
stoppedresultPOST /{id}/stopgives.
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 typeThe refusal 409 conflictturn_in_progress: true429 rate-limitedthe message rate, or too many AI messages running at once 402 bundled-llm-consent-requirednot opted in to the included AI 402 bundled-llm-budget-exhaustedthis month’s included-AI budget is used up 403 forbiddenthe included AI is not on the plan, or the model needs your own key 502 byok-anthropic-requiredno Anthropic key was available — without key_rejected: trueThe same holds for
429 concurrency-limitwithai_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
409withidempotency_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,
502withkey_rejected: true,500,503withcode: "ai_provider_unavailable", a200withkind: "refuse", and the409for a session that is closed or paused. -
Check before you repeat work that changes things. After a
5xx, or a409that carriespartial_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
onSteptomessage()to get each step as it finishes, andonEventfor the other progress events (on_step/on_eventin Python,OnStep/OnEventin 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
responseas the only result:eventdataphase{ phase }—planning,starting_browser,executing,reading_pageoransweringplan{ total, intents, labels }— the steps it is about to runstep_start{ index, total, label }— a step is starting, e.g. “Opening portal.example.com”step{ index, result }— a step finishedanswer{ 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, taketranscript_lengthfromget(id)first and stop atindex === 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
indexyou saw back aslastEventId(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 a429that says how long to wait. On a long session one call may stop before the end with aTransportError: each SDK holds one stream response to 8 MiB, and the server closes a stream whose reader fell behind (atranscript.errorevent). Call it again withlastEventIdset to the lastindexyou 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_idnames 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
answerandnotice. 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.
Related
- Agent sessions API reference — every field, endpoint and error.
- Bundled LLM — paying for the AI.
- Idempotency keys — how keys are matched and how long they last.
- Live video — watch the session’s screen.