How runs work
The life of a run, from the moment it is queued to the budget check after it finishes.
A run is one execution of one agent. The SAT API records runs but never executes them. A runner, such as sat runner or one you build, claims queued runs, executes them with an agent CLI and reports back. This page describes the contract between the API and any runner. Everything here applies to every runner, not only sat runner.
Lifecycle
Queue
The API creates a run with status queued in three cases. The run's trigger field records which one.
| Trigger | Created by | Task |
|---|---|---|
assignment | Creating a task with an assignee, or reassigning a task, when the agent is available and the task status is not backlog, done or cancelled | The task |
manual | POST /agents/{agent_id}/wake. The scheduler uses this endpoint for agent heartbeats, with reason heartbeat | Optional task_id |
routine | POST /routines/{routine_id}/trigger, from the scheduler or a person | A new task created from the routine |
The first transcript entry of a new run is a system entry with the reason, for example Assigned NF-12 or heartbeat.
An agent is available when its status is not paused, pending_approval or terminated. Waking or triggering a routine for an unavailable agent fails with 400 INVALID_STATE. Assigning a task to one queues nothing.
If an identical run is already waiting (same agent, same task or no task, same routine or no routine, status queued), the API returns that run instead of creating a new one. Two wakes for Grace without a task therefore produce one queued run, and the second reason is not recorded.
The API publishes run.created with the run id and agent_id, also when it reuses a queued run.
Claim
A runner calls POST /api/companies/{company_id}/runs/claim with its runner_id. See Claim rules.
Context
The claim response is the run context. It holds everything the runner needs, so the runner makes no other read before it starts.
| Field | Contents |
|---|---|
run | The run, including its transcript, trigger and runner_id |
agent | The agent: name, title, role, adapter, capabilities, instructions, budget |
manager | The agent the run's agent reports to, or null |
company | id, name, mission, task_prefix |
task | The task with its key (for example NF-12), or null for a heartbeat without a task |
comments | The task's comments, oldest first, with author_name |
goals | The task's goal, then each parent goal up to the root |
project | The task's project, including repository, or null |
lease_seconds | The lease length, RUN_LEASE_SECONDS |
GET /runs/{run_id}/context returns the same object without claiming the run. Use it to inspect a run.
Start the task
If the task status is backlog or todo, sat runner moves it to in_progress, passing run_id so the move is attributed to the agent. Moving to in_progress stamps the task's started_at.
Spawn
The runner prepares a workspace, builds the prompts and starts the agent CLI through its adapter.
Stream
sat runner buffers the agent's output and sends it with PATCH /runs/{run_id} and append_transcript every 2 seconds, or sooner when 4,096 characters are waiting. Each entry has ts, role (agent, tool, system; the API also accepts user) and text. Any PATCH also refreshes the lease.
Heartbeat
Every 30 seconds the runner calls POST /runs/{run_id}/heartbeat. A 409 RUN_LEASE_LOST answer means the runner must stop the agent and report nothing more. See Leases and expiry.
Follow-ups
When the agent finishes, the runner comments on the task and moves it, while the run is still running. See Follow-up rules.
Finish
The runner sends PATCH /runs/{run_id} with status (succeeded or failed), summary, error, tokens_in, tokens_out and cost_cents. These values replace the stored ones; they are totals, not increments. The API then:
- Stamps
ended_at. - Sets the agent to
idle, or toerrorafter a failed run, if it has no other running run. - Records an audit event with actor
system. - Publishes
run.updatedwithstatus, andagent.updated.
Budget
After every finished run, whether it succeeded, failed, was cancelled or expired, the API adds up the agent's cost_cents for runs created since the first day of the current month (UTC). If the agent has a non-zero monthly_budget_cents and the total is equal or higher, the API:
- Sets the agent to
paused. - Opens a
budget_overrideapproval, unless one is already pending. - Publishes
approval.created.
Queued runs for a paused agent stay queued, and claims skip them. Approving the override resumes the agent. See Approvals.
Claim rules
POST /runs/claim takes this body:
Prop
Type
The API:
- Expires stale runs first, exactly as
POST /runs/expire-staledoes. - Selects queued runs in this company, oldest first (by
created_at, thenid). - Skips runs whose agent is
paused,pending_approvalorterminated. An agent inerroris not skipped. - Skips runs whose agent already has a
runningrun. An agent executes one run at a time. - Applies the
agent_idsandadaptersfilters. - Marks the run
runningwith a conditional update (WHERE status = 'queued'), and on Postgres also takes the row withFOR UPDATE SKIP LOCKED. If another runner claimed the row first, it tries the next candidate, up to five times. - Sets
runner_id,started_atandheartbeat_at, sets the agent torunning, and adds the transcript entryClaimed by runner <runner_id>. - Publishes
run.updated(withstatus: "running") andagent.updated.
The answer is 200 with the run context, or 204 No Content when nothing matches. Two runners can never execute the same run.
Start runs only through claim
PATCH /runs/{run_id} with status: "running" also starts a run, but it sets no runner_id and skips every claim rule. Runners must use POST /runs/claim.
Leases and expiry
A claimed run holds a lease. The lease is RUN_LEASE_SECONDS long (an API environment variable, default 300). It is renewed by every heartbeat and by every PATCH /runs/{run_id}.
A running run expires when its heartbeat_at (or started_at, if it never had a heartbeat) is older than the lease. Nothing expires runs on a timer inside the API. Expiry happens when someone calls:
POST /runs/claim, before it selects a run.POST /runs/expire-stale, which the scheduler calls every 60 seconds by default.
An expired run becomes failed with the error Runner <runner_id> stopped reporting (lease expired), or The process running it stopped reporting (lease expired) for a run without a runner_id. Its agent moves to error (if it has no other running run), its budget is checked, and the API publishes run.updated and agent.updated.
The heartbeat endpoint answers 409 RUN_LEASE_LOST when the run is no longer running (message Run is cancelled, Run is failed, and so on) or when runner_id does not match (message ends with under another runner). PATCH /runs/{run_id} answers 409 RUN_LEASE_LOST when you send a runner_id that differs from the claiming runner, and 400 RUN_FINISHED when the run already finished. A runner treats all three as "stop now and report nothing".
Cancellation
You can cancel a queued or running run.
Open the run's detail page and choose Cancel run. The button is shown while the run is queued or running.
Cancelling is PATCH /runs/{run_id} with status: "cancelled" and no runner_id, so it works whichever runner holds the run. The API stamps ended_at, frees the agent, checks its budget and publishes run.updated with status: "cancelled".
sat runner reacts to that event and kills the agent process (SIGTERM, then SIGKILL after 5 seconds). If the live stream is down, the next heartbeat (within 30 seconds) returns 409 and has the same effect. A cancelled run gets no follow-ups: the task keeps its status, usually in_progress.
Terminating an agent cancels its queued and running runs with the error Agent terminated.
Follow-up rules
sat runner applies these rules after the agent ends and before it finishes the run. Every write passes run_id, so it is attributed to the agent.
| Outcome | What the runner does |
|---|---|
The agent's final message starts with BLOCKED: (any case), whether or not the run succeeded | Posts the message as a question comment and moves the task to blocked |
| The run failed | Posts the comment Run failed: <error> and leaves the task status alone |
| The run succeeded | Posts the final message as a comment (if there is one). Then, if the task is still todo or in_progress, moves it to in_review, or to the status given by --on-success (done, or none to skip the move) |
The success move reads the task first. If the agent already moved the task itself, for example to done or blocked, the runner leaves it there. Comments are cut to 8,000 characters and the run summary and error to 2,000.
The run status is succeeded or failed by the adapter's result, independent of BLOCKED:. A blocked reply from a successful run is a succeeded run on a blocked task.
Attribution with run_id
While a run is running, a runner can act as its agent. Pass the run's id as run_id in:
POST /tasks(create a task or subtask)PATCH /tasks/{task_ref}(move, assign or edit)POST /tasks/{task_ref}/comments
The API then records the agent as the actor in the audit log, sets created_by_agent_id on new tasks and author_agent_id on comments, and the web app shows the agent's name. If the run is not running, the request fails with 400 RUN_NOT_RUNNING. This is why follow-ups happen before the finishing PATCH.
Agents get the same power: sat runner sets SAT_RUN_ID in the agent's environment, and the sat CLI adds run_id to tasks create, tasks move, tasks assign, tasks edit and tasks comment.
Known gap
The API checks only that the run is running and belongs to the company. It does not check that the caller is the runner that claimed it. Any member who knows a running run's id can attribute writes to its agent.