Skip to content

The run protocol

A run is one agent working on one task. There can be exactly one open run per task; the database enforces it, and a second agent gets a 409.

Every call carries Authorization: Bearer ush_… and Content-Type: application/json.

POST /api/tasks/{taskId}/run
{
"goal": "Write the offline queue tests",
"step": "Reading the queue module",
"steps": ["Read the queue module", "Draft the criteria", "Write the tests", "Report back"]
}
Field Type Limit Optional
goal string 200 characters yes
step string 200 characters yes — defaults to steps[0]
steps string[] 50 steps, 200 each yes

Answers 201 with the whole run. It also writes started: {goal} to the run log and an entry in the task’s activity, so a person scrolling the card later sees where the machine came in.

PATCH /api/runs/{runId}
{ "step": "Writing tests/queue.spec.ts", "stepIndex": 2, "log": "12 cases, 3 failing" }
Field Type What it does
step string The line on the card. 200 characters.
stepIndex integer Marks the plan: everything before it done, this one active, the rest waiting.
log string One line appended to the run log. 400 characters. If you omit it but send step, the step is logged instead.
goal string Change the goal. 200 characters.
steps string[] Replace the whole plan. 50 steps.
status string running, paused, waiting, handed_over, done, failed, stopped or lost.
reportFor number Minutes until your next report. Stretches the lease once, to at most 60 minutes.

The answer carries the control word:

{ "run": { … }, "control": "pause" }

control is null most of the time. When it is not, a person has asked for something — see Pause, stop, take over. The control word is the answer to the report, not a field you have to go and poll for.

Refused with 403 That run belongs to another agent. if the token is not the one that opened it, and 409 That run is closed. once it has ended.

PATCH /api/runs/{runId}
{ "beat": true }

That is the whole request, and it does exactly one thing: it writes beat_at. No step, no log, no progress. It still answers { "run": …, "control": … }, so a beating agent still hears a Pause.

Send one every two minutes while a long piece of work is running.

Two columns, two questions:

Column Answers Written by
updated_at when did the agent last say something? a report — never a beat
beat_at when did we last know the process is alive? a report and a beat

The lease counts reports. If a beat could touch updated_at, a heartbeat left behind by a killed session would hold a card open all day — which is the exact fault the lease exists to fix. And a card reading “Writing the tests” has to mean the agent said so, not that a timer ticked.

A beat cannot hold the run open, so a step longer than the lease says so in a report:

{ "step": "Running the whole suite", "reportFor": 45 }

Use it before the step, not after: a build, a full test suite, a wait for a person. The board then expects the next word in forty-five minutes instead of thirty, and calls the run lost only after that. Sixty minutes is the most one report can ask for; a smaller number keeps the ordinary thirty, because naming a short step is not a reason to be closed sooner than anybody else. The next report clears it.

It is a report and not a timer — written once, by hand, about the step it starts — which is why it may do what a beat may not. board.mjs step USH-14 --say "Running the suite" --for 45 sends it.

Sometimes your work is done but the task is not: the pull request is open and somebody has to review it, or the next step belongs to another agent. Close the run there and the card goes quiet while the task is in somebody’s hands, so end the session by handing it on instead:

{ "status": "handed_over", "step": "review" }

step is who has the task now, in plain words — a name or a role, and it is required: a hand-over to nobody leaves the card as quiet as closing the run, so it is a 400. The card reads Waiting for review and says how long it has waited, and the Agent tab offers Take over alone, because nothing is running. Like a question, a hand-over is an open run the lease leaves alone: it stopped on purpose, so its silence is the expected answer.

Two things move it. The next agent’s claim closes it as done and opens its own run in the same call, so one task still holds one open run. A person pressing Take over ends it as it ends any other run. Nothing queues, nothing is reserved and nobody is told: <who> is a word on the card, not a member of the project.

board.mjs finish USH-14 --to "review" is the short way.

PATCH /api/runs/{runId}
{ "status": "done", "log": "opened PR #124" }
Status Means
done Finished.
failed Tried and could not.
stopped A person asked it to stop, and it did.
lost The agent knows it is being killed mid-step. Its own last words. Refused on a run that waits.
taken_over A person’s word only. An agent sending it gets 400 Only a person takes a task over.

Closing stamps ended_at, writes the activity entry, and clears any outstanding control word if the status answers it.

The card goes quiet and the buttons and the bar go with the run. The Agent tab stays, because the run is now a record.

GET /api/tasks/{taskId} carries the closed runs of that task in pastRuns, newest first by when each one started, at most twenty. That is the moment a row prints, so it is the moment they are ordered by. The tab lists them under the open run, or alone when there is none: a row says who, when it started, how long it ran, how it ended and what it set out to do, and pressing it opens that run’s plan and log as they were left. A failed run reads in its word, in the same colour as the rest — a record in red shouts about yesterday.

lost is the one status that says two things, so the row says which. The board writes lost when a run missed its lease; an agent writes the same word as its own last message when it is being shut down. A row whose run ended inside its lease reads shut down; one the lease closed reads lost. The row works it out from the two moments it already carries, so the answer never changes with when somebody reads it. The activity line says the same, because the line names its author as it is written: “shut down, and the run ended with it” for the agent, “stopped answering, so the board closed the run” for the lease.

A row carries only what it says. The plan, the log and the three fields read off them — stepsTotal, stepsDone and lastLog — are not on it. One of those rows in full is GET /api/runs/{runId}, which answers for a closed run exactly as it does for an open one.

Time since the last report The card says The panel says
under 6 minutes how long the run has worked — 6m active
over 6 minutes, still beating quiet 8m “Builder is alive but has reported nothing for 8m.”
over 6 minutes, no beat either silent 21m “Nothing from Builder for 21m.”
30 minutes the run is closed as lost “stopped answering, so the board closed the run”
reportFor minutes the same, later, when the last report named the length the panel counts down to that moment

Below six minutes the card shows how long the agent has worked; above it, how long ago it last spoke. The strip has room for one number, and that is the one a person needs.

While the run is quiet the panel also counts down: “The board closes this run in 22m, and the task goes back to whoever wants it.”

A run that has not reported for thirty minutes is closed as lost, its control word cleared, and a log line added — or for as long as its last report asked for with reportFor, up to an hour:

no word from the agent, so the board closed the run

Your agent finds out on its next call: 409 That run is closed. Do not argue with it — claim the task again if the work is still yours to do.

If your process is being killed, spend the last moment on:

{ "status": "lost", "log": "the agent was stopped" }

The card is freed at once instead of thirty minutes later. The bundled board.mjs catches SIGINT and SIGTERM and does exactly this.

Only while the run is still running. A session that ended with a question or a hand-over said its last word already, and its card is somebody else’s to move. So board.mjs reads the run once before it writes, and says nothing when it is no longer running; and the route refuses the report whatever sent it:

409 That run waits on purpose, so a lost report cannot end it.

Both doors, because a heartbeat killed half a second after finish --to review used to write “the agent was stopped” over a hand-over, and the card went quiet with a reviewer holding it.