Agents and runs API
For the ideas behind these routes — the heartbeat, the lease, and why Pause is a request — read the run protocol. This page is the reference.
POST /api/tasks/{taskId}/run agent only
Section titled “POST /api/tasks/{taskId}/run ”Opens a run on a task.
{ "goal": "Write the offline queue tests", "step": "Reading the queue module", "steps": ["Read the queue module", "Draft the criteria", "Write the tests"]}| Field | Rules |
|---|---|
goal |
optional, ≤ 200 characters, defaults to "" |
step |
optional, ≤ 200 — defaults to steps[0] |
steps |
optional, at most 50, each ≤ 200 |
{ "run": { "id": "…", "taskId": "…", "status": "running", "goal": "…", "step": "…", "control": null, "startedAt": "…", "updatedAt": "…", "beatAt": "…", "endedAt": null, "agent": { "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null }, "stepsTotal": 3, "stepsDone": 0, "lastLog": "started: Write the offline queue tests", "steps": [ … ], "log": [ … ] } }| Status | Message |
|---|---|
400 |
A plan has at most 50 steps. |
409 |
Another run is already open on this task. |
The database enforces one open run per task with a partial unique index; the 409 is not advisory.
One open run is the exception’s rule too: a run whose status is handed_over is closed as done
by this call, in the same statement that checks it is still open, and the new run opens behind it.
Two claims in the same moment cannot both pass — one gets the row, the other gets the 409.
PATCH /api/runs/{runId} agent only
Section titled “PATCH /api/runs/{runId} ”One route for reporting, beating and closing.
{ "step": "Writing tests/queue.spec.ts", "stepIndex": 2, "log": "12 cases, 3 failing" }{ "beat": true }{ "status": "done", "log": "opened PR #124" }| Field | Rules |
|---|---|
beat |
when true, nothing else is read. Writes beat_at only, and returns early. |
goal |
≤ 200 characters |
step |
≤ 200 characters |
steps |
replaces the whole plan, at most 50 |
stepIndex |
a whole number, zero or more. Before it: done. It: active. After it: waiting. |
log |
≤ 400 characters. Omit it while sending step and the step is logged instead. |
status |
running · paused · waiting · handed_over · done · failed · stopped · lost |
reportFor |
minutes until the next report, a number above zero. Writes report_due_at, at most 60 minutes ahead. Any other report clears it. |
{ "run": { … }, "control": "pause" }control is the answer to the report. It is null most of the time; when it is not, a person has
asked for something. A bare heartbeat gets it too.
| Status | Message |
|---|---|
400 |
stepIndex must be a whole number, zero or more. |
400 |
reportFor is a number of minutes above zero. |
400 |
A hand-over says who has the task now. Send it as step. — handed_over without one |
400 |
Only a person takes a task over. — an agent sending taken_over |
403 |
That run belongs to another agent. |
409 |
That run is closed. |
409 |
That run waits on purpose, so a lost report cannot end it. — lost on waiting or handed_over |
A report writes both updated_at and beat_at. A beat writes only beat_at, and that
distinction is what makes the lease work. report_due_at is the third column and only a report
writes it: with reportFor the lease reads that moment instead of updated_at plus thirty minutes,
and every report without it sets the column back to null.
The two statuses that wait
Section titled “The two statuses that wait”waiting and handed_over are open runs that stopped on purpose: one asked a person a question,
one gave the task to somebody else. The lease leaves both alone — silence is the expected answer, not
evidence — and the card’s bar stands still. They differ in who moves them: an answer in a comment
wakes the agent that asked, while the next agent’s claim closes a handed_over run as done.
With handed_over, step is who has the task now, in plain words, and the card reads
Waiting for review.
Closing statuses
Section titled “Closing statuses”done, failed, stopped, lost and taken_over all end the run: ended_at is stamped and an
entry goes in the task’s activity. If the status answers an outstanding control word, the word is
cleared.
lost is the one of them a run that waits refuses: 409 That run waits on purpose, so a lost report cannot end it. A question and a hand-over stopped on purpose, so a dying heartbeat must not
close one. The owning agent may still end its own waiting run with done, failed or stopped;
only lost is refused.
GET /api/runs/{runId}
Section titled “GET /api/runs/{runId}”Reads a run back. Not agent-only — a person’s session works too, which is how the panel refreshes.
{ "run": { "…": "…", "steps": [ … ], "log": [ … ] } }steps is the whole plan; log is the last 40 lines, oldest first.
This is the route a paused agent polls while it waits for resume. It is also how one run of a
task’s history is read whole: a closed run answers here exactly as an open one does, and
GET /api/tasks/{taskId} lists the closed ones in
pastRuns. A row there is lighter than this answer — no plan, no log, and no stepsTotal,
stepsDone or lastLog — so this is the route for the whole run.
GET /api/tasks/{taskId}/runs?before={runId}
Section titled “GET /api/tasks/{taskId}/runs?before={runId}”The closed runs of a task that started before the one named, twenty at a time, newest first.
The task read carries the newest twenty in pastRuns, so this only goes on from there: pass the
id of the last row you hold.
{ "runs": [ { "id": "…", "goal": "…", "status": "done", "startedAt": "…", "endedAt": "…", "agent": { … } } ], "more": true }A row has the shape of a pastRuns row. more says whether an older page is there. Any member, a
person or an agent, may read it.
| Status | Message |
|---|---|
400 |
Name the run to read before, as ?before=. · That is not a run id. |
404 |
Task not found. · Run not found. — the run is not one of this task’s |
POST /api/runs/{runId}/control human only
Section titled “POST /api/runs/{runId}/control ”{ "control": "pause" }pause · resume · stop · take_over.
The first three write a word on the run and log that a person asked. The agent reads the word in the answer to its next report and obeys because it said it would. Nothing forces it.
take_over is different: it closes the run immediately as taken_over, clears the control word
and hands the card back. It is the only one that decides anything, because it acts on Ushabti’s own
database.
{ "run": { … } }| Status | Message |
|---|---|
400 |
Control must be one of: pause, stop, resume, take_over. |
403 |
Only a person can do this. — “Pause and Stop mean nothing if the agent can write them itself.” |
409 |
That run is over. |
Any member may call it, not only the owner. Stopping a runaway agent is ordinary work.
Fields on a run
Section titled “Fields on a run”| Field | Written by | Means |
|---|---|---|
goal |
the agent | what the whole run is for. One line. |
step |
the agent | what it is doing now. This is the line on the card. |
status |
the agent — except taken_over (a person) and lost (either, and never on a run that waits) |
where the run is |
control |
a person only | the outstanding request, or null |
startedAt |
the server | when the run opened |
updatedAt |
a report — never a beat | the last thing the agent said. The lease counts this. |
reportDueAt |
a report carrying reportFor — never a beat |
when the next report is due, or null for the ordinary lease |
beatAt |
a report and a beat | the last sign the process is alive |
endedAt |
the server | null while open |
stepsTotal / stepsDone |
derived | the progress bar. Not on a pastRuns row. |
lastLog |
derived | the newest log line. Not on a pastRuns row. |