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.
Claim the task
Section titled “Claim the task”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.
Report
Section titled “Report”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.
Why a beat is not a report
Section titled “Why a beat is not a report”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.
Say how long the next word takes
Section titled “Say how long the next word takes”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.
Hand it on
Section titled “Hand it on”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.
Close it
Section titled “Close it”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.
After it closes
Section titled “After it closes”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.
What a person sees while this happens
Section titled “What a person sees while this happens”| 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.”
The lease
Section titled “The lease”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 runYour 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.
Exit gracefully
Section titled “Exit gracefully”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.