How runs and sessions end
Every way an agent run or a session ends, what to do about each, and the habits that keep sessions from running longer than you want: stop in finally, timeouts, keepAlive, idleTimeout, cancelling, reconnecting.
Every session and every agent run ends for a reason, and the reason is always on it: stopReason on the session, errorCode on the run. The session.stopped and agent_run.finished webhooks carry them too. A session always stops: it is saved and free, and you can resume it. It is deleted only when you delete it, or when your plan’s retention days pass. This page puts the rules in one place, with the habits that keep sessions from running longer than you want.
One job per setting
| Setting | On | What it does |
|---|---|---|
timeout | The session | Its one clock: the session stops at expiresAt, which it always shows. Change it while the session runs with Update a session. A run's own session takes timeout in the run's session (default 30 minutes, or your plan's maximum when shorter). |
idleTimeout | The session | Opt-in: it stops (idle) after that many seconds with no activity at all. 30 seconds up to its timeout. |
keepAlive | The session | Only about disconnects: a browser-only session without it stops about 5 seconds after its last client leaves. It never makes a session last longer than its timeout. |
maxSteps | The run | Its work budget: 30 by default, 1 to 1,000, or null for no step limit (the session's time then bounds it). |
maxCostUsd | The run | Its money budget, 0.01 to 100 dollars of model cost (optional). One model call can go a little past it. |
maxConsecutiveErrors | The run | How many tool errors in a row stop it: 5 by default, 1 to 20. A tool that works resets the count. |
Waiting for a person (a hand-over, a CAPTCHA) uses the session’s time, not steps. A run without a step limit should have a cost limit or a sensible timeout.
When a run stops
A run stops at the first of these. The limits are checked before each model call.
| Case | errorCode | Can be resumed | Its session |
|---|---|---|---|
| It finishes (status completed) | null | – | Its own session is stopped first (agent_run, or task for a task's run), so you can resume it. |
| It uses all its steps | max_steps | Yes | Its own session is kept running for the resume window, then stopped. |
| It reaches maxCostUsd | max_cost | Yes | As at the step limit. |
| maxConsecutiveErrors tool errors in a row | too_many_errors | Yes | As at the step limit. |
| The same call with the same result 5 times in a row | no_progress | Yes | As at the step limit. |
| The API server running it stopped, and the session goes on | server_restarted | Yes | As at the step limit. |
| Its session reaches its time | session_timeout | No | The session stops (timeout). |
| Its session stops or is deleted another way (you stopped it, its machine was lost, …) | session_ended | No | As it stopped; error names its stopReason (or says it was deleted). |
| The plan's monthly model spend cap is reached | spend_limit | No | Its own session is stopped. |
| Your organization or project reaches its monthly spending limit, and the run is refused before its next step | spend_limit | No | Its own session is stopped. |
| The spending limit stops its session first (within about a minute) | session_ended | No | Stopped (spend_limit); error names it. |
| You cancel it (status canceled) | null | No | Its own session is stopped; a session you passed in stays. |
A session you passed in with sessionId is never stopped by the run. If it was stopped, the run resumes it first. With keepSession: true, the run’s own session keeps running until its expiresAt, with keepAlive on: stop it yourself, or give it an idleTimeout.
The resume window
A run that stopped at one of its limits keeps its progress. While resumable is set ({ until }), Resume a run: the same run goes on in the same browser and shell, with the same run id. Its own session is kept for 10 minutes for that, or less when the session’s own time ends sooner (until is the earlier of the two). The session is billed like any running session meanwhile, and stopped when the window passes unused. See Pause and resume a run.
When a session stops
| stopReason | What happened |
|---|---|
requested | You, the console, or the CLI or an SDK stopped it. |
timeout | It reached its expiresAt. |
idle | Its idleTimeout passed with no activity (checked every few seconds, so it can stop up to about 15 seconds late). |
no_clients | A browser-only session without keepAlive lost its last client, with no agent run, script or step working in it. |
agent_run | The agent run that created it finished, or its resume window passed unused. |
task | The run of a task that created it finished. |
machine_lost | Its machine failed and could not be recovered. It keeps its last checkpoint; with none, nothing was saved. |
api_restart | The API restarted and the session's machine could not be taken back, with no checkpoint to start it again from. Nothing was saved. |
account_recovered | A password reset recovered an account whose email was not confirmed. Nothing is kept. |
suspended | An admin suspended the organization. Its running sessions stop, saving what they can. |
spend_limit | The organization or the project reached the monthly spending limit its owners set. Running sessions stop within about a minute. |
out_of_credit | No credit left. Used only when refusing work at zero credit is switched on, which it is not yet: today, running out of credit only sends an alert. |
Every stop saves the session as it is, except where the table says nothing was saved. A stopped session is free and is kept for your plan’s retention days (its deletesAt), then deleted. See How long a stopped session is kept. A session whose machine failed comes back from its last checkpoint when it has one; without one it stops with stopReason: "machine_lost" and the checkpoint is what it saved. See Checkpoints and recovery.
What counts as disconnected
- Only browser-only sessions without
keepAlivestop on disconnect, about 5 seconds after their last Playwright or Puppeteer client leaves. The live view is not a client for this. - Never while an agent run, a script or a plain-English step works in the session, or while a run in it can still be resumed. When the last of these ends and nobody is connected, the 5 seconds start then.
- Sessions with a shell, and sessions nobody ever connected to (API-only use), run until their timeout.
When the API restarts
When the platform’s API restarts, for example during a deploy, sessions keep running: the new API takes their machines back. api_restart now means only a session whose machine could not be taken back and had no checkpoint.
An agent run’s loop lives in the API server that runs it. When that server stops, the run ends at once when the same server starts again, or within about 90 seconds when another server notices, as server_restarted: the browser and shell kept their state, so resume the run. Until then, pause, resume and messages answer 409 run_not_live, and its event stream gets its done event on whichever server you are connected to.
Best practices
Stop sessions in finally
A session is billed per second until it stops. Stop it when you are done, also when your code fails on the way. A stopped session is free, and you can resume it if you need it again:
SESSION_ID=$(curl -s -X POST https://api.boxline.dev/v1/sessions -H "x-api-key: $BOXLINE_API_KEY" -H "content-type: application/json" -d '{"timeout": 600}' | jq -r .id)trap 'curl -s -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/stop -H "x-api-key: $BOXLINE_API_KEY" > /dev/null' EXIT# … work with the session …import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create({ timeout: 600 });try { // … work with the session …} finally { await session.stop(); // also when something failed}from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URL# Leaving the "with" block stops the session, also after an error.with bx.sessions.create(timeout=600) as session: ...SESSION_ID=$(boxline sessions create --timeout 600 --json | jq -r .id)trap 'boxline sessions stop "$SESSION_ID"' EXIT# … work with the session …Choose timeouts
- Give each session the longest time its job needs, plus a margin, not your plan’s maximum. It stops the bill if everything else goes wrong.
- For an agent run in its own session, set
timeoutin the run’ssession. A run whose session reaches its time fails withsession_timeoutand cannot be resumed, so give long jobs enough time up front. - To go on past it, raise the session’s
timeoutwhile it runs, with Update a session: the new number is its whole length from its last start, up to your plan’s longest session. A run shows its session’s end assessionExpiresAt, and thesession.expiringwebhook comes about a minute before.
curl -X PATCH https://api.boxline.dev/v1/sessions/$SESSION_ID \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"timeout": 2400}' # it had 1800: ten more minutesimport { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst { id, sessionExpiresAt } = await bx.agent.run({ task: "…", session: { timeout: 1800 } });// Later, if the job needs longer than planned:const run = await bx.agent.get(id);const session = await bx.sessions.get(run.sessionId);await session.update({ timeout: session.data.timeout + 600 }); // ten more minutes, within your plan's longest sessionfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLstarted = bx.agent.run(task="…", session={"timeout": 1800})# Later, if the job needs longer than planned:run = bx.agent.get(started["id"])session = bx.sessions.get(run["sessionId"])session.update(timeout=session.timeout + 600) # ten more minutes, within your plan's longest sessionkeepAlive only covers disconnects
keepAlive: true keeps a browser-only session running between connections. It does not end the session for you and does not make it outlive its timeout: the session runs, and is billed, until you stop it or it reaches its time. Pair it with an idleTimeout.
idleTimeout for crashed clients
When a client crashes, a keepAlive session, or one with a shell, has nothing that ends it until its timeout. idleTimeout stops it after that many seconds with no activity: CDP commands, live-view input, terminal keys, exec, files, actions and steps, scripts, and agent runs. A run working in the session, or one that can still be resumed, counts as activity the whole time.
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"keepAlive": true, "timeout": 3600, "idleTimeout": 300}'# Change it later (counted from then), or null to switch it off:curl -X PATCH https://api.boxline.dev/v1/sessions/$SESSION_ID \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"idleTimeout": 600}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create({ keepAlive: true, timeout: 3600, idleTimeout: 300 });// Change it later (counted from then), or null to switch it off:await session.update({ idleTimeout: 600 });from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create(keep_alive=True, timeout=3600, idle_timeout=300)# Change it later (counted from then), or None to switch it off:session.update(idle_timeout=600)boxline sessions create --keep-alive --timeout 3600 --idle-timeout 300Your own wait does not cancel a run
When your code stops waiting for a run, the run goes on. bx.agent.wait() giving up, a closed event stream, or your process exiting never stops it. Cancel it when you no longer want it:
curl -X POST https://api.boxline.dev/v1/agent/runs/$RUN_ID/cancel \ -H "x-api-key: $BOXLINE_API_KEY"import { Boxline, BoxlineTimeoutError } from "@boxline/sdk";const bx = new Boxline();const { id } = await bx.agent.run({ task: "…" });try { const run = await bx.agent.wait(id, { timeoutMs: 10 * 60_000 }); console.log(run.status, run.result);} catch (err) { if (err instanceof BoxlineTimeoutError) await bx.agent.cancel(id); // the run would go on otherwise else throw err;}from boxline import Boxline, BoxlineTimeoutErrorbx = Boxline()started = bx.agent.run(task="…")try: run = bx.agent.wait(started["id"], timeout=600) print(run["status"], run["result"])except BoxlineTimeoutError: bx.agent.cancel(started["id"]) # the run would go on otherwiseReconnecting
- A dropped CDP connection: connect again to the same
connectUrl. A browser-only session withoutkeepAlivewaits about 5 seconds for you; usekeepAlivewhen you need longer. - When the platform replaces a machine: clients are disconnected; connect again to the same
connectUrl, which now points at the new machine. - A stopped session: call resume first (
POST /v1/sessions/:id/resume). Connecting, or calling actions, exec or files, on a stopped session answers409 session_not_running. An agent run given its id resumes it. - A run’s events: open
GET /v1/agent/runs/:id/events/streamagain. A late viewer first gets the steps so far, and the running command with its output. - After changing
timeout: take the fresh URLs from the answer (or read the session again); a URL’s token lasts until 5 minutes after theexpiresAtit was signed with.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.