Smart Agent Teams

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.

TriggerCreated byTask
assignmentCreating a task with an assignee, or reassigning a task, when the agent is available and the task status is not backlog, done or cancelledThe task
manualPOST /agents/{agent_id}/wake. The scheduler uses this endpoint for agent heartbeats, with reason heartbeatOptional task_id
routinePOST /routines/{routine_id}/trigger, from the scheduler or a personA 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.

FieldContents
runThe run, including its transcript, trigger and runner_id
agentThe agent: name, title, role, adapter, capabilities, instructions, budget
managerThe agent the run's agent reports to, or null
companyid, name, mission, task_prefix
taskThe task with its key (for example NF-12), or null for a heartbeat without a task
commentsThe task's comments, oldest first, with author_name
goalsThe task's goal, then each parent goal up to the root
projectThe task's project, including repository, or null
lease_secondsThe 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 to error after a failed run, if it has no other running run.
  • Records an audit event with actor system.
  • Publishes run.updated with status, and agent.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_override approval, 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:

  1. Expires stale runs first, exactly as POST /runs/expire-stale does.
  2. Selects queued runs in this company, oldest first (by created_at, then id).
  3. Skips runs whose agent is paused, pending_approval or terminated. An agent in error is not skipped.
  4. Skips runs whose agent already has a running run. An agent executes one run at a time.
  5. Applies the agent_ids and adapters filters.
  6. Marks the run running with a conditional update (WHERE status = 'queued'), and on Postgres also takes the row with FOR UPDATE SKIP LOCKED. If another runner claimed the row first, it tries the next candidate, up to five times.
  7. Sets runner_id, started_at and heartbeat_at, sets the agent to running, and adds the transcript entry Claimed by runner <runner_id>.
  8. Publishes run.updated (with status: "running") and agent.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.

OutcomeWhat the runner does
The agent's final message starts with BLOCKED: (any case), whether or not the run succeededPosts the message as a question comment and moves the task to blocked
The run failedPosts the comment Run failed: <error> and leaves the task status alone
The run succeededPosts 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.

On this page