Follow every run
Every run of every kind lands on one record: agent runs, automations, dataset reads, knowledge syncs, Scan. Read its state, its steps, and its events in order, from the same routes Studio uses for its Runs page.
- For
- Developers and architects
- Record
- Header · steps · events · children
- Order
seqwithin a run- Read with
- GET /runs
01One record for every run
A run has a header, its steps, and its events. The header says what ran, who or what started it, where, and when. Steps are the phases and tool calls, each with a status. Events are the full sequence, numbered from 1.
RUNS="$NSTACK_BASE/core/api/v1/organizations/$NSTACK_ORG/runs"
curl -s "$RUNS/$RUN_ID" -H "Authorization: Bearer $NSTACK_SESSION"
{
"run": {
"run_id": "0b6c1d2e-3f40-4a5b-8c6d-7e8f9a0b1c2d",
"kind": "agent_run",
"title": "Test Meeting prep v4",
"status": "succeeded",
"actor": { "type": "user", "id": "user_2abc", "label": "Dana Whitfield" },
"producer": "agents",
"queued_at": "2026-10-02T14:30:01.902Z",
"ended_at": "2026-10-02T14:34:19.415Z"
},
"steps": [
{ "step_key": "context", "name": "context", "kind": "phase", "status": "succeeded" },
{ "step_key": "tools", "name": "tools", "kind": "phase", "status": "succeeded" },
{ "step_key": "tool:query_accounts#1", "parent_step_key": "tools", "kind": "tool", "status": "succeeded" },
{ "step_key": "tool:search_knowledge#1", "parent_step_key": "tools", "kind": "tool", "status": "succeeded" }
],
"children": []
}
Read runs, which the Admin role holds today. A member without it reads nothing from these routes.
02The state model
A run's status only moves forward, and a terminal status is final. running and waiting can alternate before the end.
| Status | Means | Your code |
|---|---|---|
queued | Accepted, not started. The first event carries the header. | Wait. |
running | A step is executing. | Wait, or read events as they land. |
waiting | Stopped for something outside the run. reason: "approval" means a person must decide. | Route it to the person. See escalations. |
succeeded | Every step finished. | Read the output and act. |
partial | Some steps finished, some did not. | Read which steps failed or were skipped. |
failed | The run ended on an error. | Read the failing step's error. See exceptions. |
canceled | Someone stopped it. | Nothing to retry unless you decide to. |
timed_out | It ran past its limit. | Treat like a failure. |
03Find runs
List runs newest first and filter by kind, subject_type, subject_id, status, env_id, actor_id, from, and to. The subject is what ran: an agent, an automation, a dataset. Pages come back as {"items": […], "next_cursor": …}.
# Everything waiting for a person in PROD
curl -s "$RUNS?status=waiting&env_id=$PROD_ENV_ID" -H "$H"
# One agent's runs this week
curl -s "$RUNS?subject_id=$AGENT_CONFIG_ID&from=2026-10-13T00:00:00Z" -H "$H"
# Everything your system started
curl -s "$RUNS?actor_id=$MACHINE_TOKEN_ID" -H "$H"
const list = async (params: Record) =>
(await fetch(`${runs}?${new URLSearchParams(params)}`, { headers: auth })).json();
const waiting = await list({ status: "waiting", env_id: prodEnvId });
const failedToday = await list({ status: "failed", from: startOfDay.toISOString() });
04Read events in order
Events are the finest grain: lifecycle, step starts and ends, logs, metrics, outputs, and kind-specific facts such as tool calls. Read them with after_seq and keep the next_after_seq you saw; each call returns 500 by default and 1,000 at most, and you see each event once.
const TERMINAL = new Set(["succeeded", "partial", "failed", "canceled", "timed_out"]);
export async function followRun(runId: string, onEvent = (e: any) => {}) {
let after = 0;
let delay = 1000;
for (;;) {
const url = `${runs}/${runId}/events?after_seq=${after}&limit=500`;
const page = await (await fetch(url, { headers: auth })).json();
for (const e of page.items) onEvent(e);
if (page.items.length) { after = page.next_after_seq; delay = 1000; }
else { delay = Math.min(delay * 2, 15000); }
const { run } = await (await fetch(`${runs}/${runId}`, { headers: auth })).json();
if (TERMINAL.has(run.status)) return run;
if (run.status === "waiting") notifyApprover(run);
await new Promise((r) => setTimeout(r, delay));
}
}
import time
TERMINAL = {"succeeded", "partial", "failed", "canceled", "timed_out"}
def follow_run(run_id, on_event=lambda e: None):
after, delay = 0, 1.0
while True:
page = requests.get(f"{runs}/{run_id}/events", params={"after_seq": after, "limit": 500}, headers=auth).json()
for e in page["items"]:
on_event(e)
if page["items"]:
after, delay = page["next_after_seq"], 1.0
else:
delay = min(delay * 2, 15.0)
run = requests.get(f"{runs}/{run_id}", headers=auth).json()["run"]
if run["status"] in TERMINAL:
return run
if run["status"] == "waiting":
notify_approver(run)
time.sleep(delay)
{
"items": [
{ "run_id": "0b6c…", "seq": 3, "event_id": "5f0c…", "type": "step.started", "step_key": "tool:query_accounts#1", "at": "2026-10-02T14:30:02.118Z", "level": null, "message": null, "data": { "kind": "tool" } },
{ "run_id": "0b6c…", "seq": 4, "event_id": "61d1…", "type": "log", "step_key": "tool:query_accounts#1", "at": "2026-10-02T14:30:02.640Z", "level": "info", "message": "4 rows", "data": {} }
],
"next_after_seq": 4
}
The Events reference lists every type and the envelope each one carries.
05Automation runs and their tasks
The workflow service keeps its own view of an automation's runs, with each task of the graph. Use it when you hold an instance_id, or when you want one automation's history without the rest of the record.
/workflow/api/v1/organizations/{org_id}/workflows/{workflow_id}/instances?status=An automation's runs, filtered by status.
NeedsRead automations
/workflow/api/v1/organizations/{org_id}/workflows/instances/{instance_id}One run and its tasks.
NeedsRead automations
/workflow/api/v1/organizations/{org_id}/workflows/instances/{instance_id}/stream-ticketA single-use ticket, valid 60 seconds at most, to follow the run live over the service's socket. Studio's run strip uses it.
NeedsRead automations
06Counts and trends
For a dashboard, skip the pages and ask for counts. GET …/runs:stats answers run counts and durations by kind, status, or environment, per hour or per day. GET …/scan/runs:stats adds the time Scan routines saved, valued with your organization's cost settings.
curl -s "$RUNS:stats" -H "$H"
curl -s "$NSTACK_BASE/core/api/v1/organizations/$NSTACK_ORG/scan/runs:stats" -H "$H"
Nothing here is estimated. The counts come from the same record Studio's Runs page reads.
07Trace it end to end
- One actor model.
{type, id, label}with the type one ofuser,machine,mcp_key,agent, orsystem, on every run and every activity entry. - One trace.
trace_idon a run and on an activity entry joins them to the request that caused them; events carry W3Ctraceparent. - Your correlation. The
correlation_ida webhook answers, and thethread_idyou give an agent, are yours to key on. - Children. A run that starts other runs lists them under
children, so an automation's agent runs sit under the automation.
08In design
- In design An event stream as server-sent events, so your code follows runs and changes without polling.
- In design A
tool_callrun per MCP call, with the key as the run's actor.