Runs and monitoring
Every fire is a normal program run: it writes a trace on the device that ran it, publishes a synced summary to the agent_runs dataset, and stamps the trigger record. "Did it run, how long did it take, what did it cost, why did it stop" are answered from records — no log grepping.
$SPACE is the agent space id. The dataset query below is the synced history; the control API is the live registry of one running runtime. A trigger that never ran is usually a question of its owner or its health markers.
The run summary
At the end of every run the runtime upserts one record into agent_runs on the bao/runs/v1 child of the bao/v1 bundle; the record id is the run id. Summaries sync, so every device sees every device's runs — trigger fires, conversations (under the chat responder's id) and control-API runs alike.
{
"runId": "run_9f3c2a9d0b4e7f61",
"triggerId": "daily-digest",
"program": "agent:rollup@v1",
"device": "12D3…",
"startedAt": 1756108800.4,
"endedAt": 1756108802.7,
"durationMs": 2311,
"status": "ok",
"errorType": null,
"turns": 1, "cells": 1, "effects": 14, "mutations": 3,
"tokens": {"in": 5120, "out": 410, "cacheRead": 0, "cacheWrite": 0},
"costUsd": 0.021,
"model": "claude-haiku-4-5-20251001",
"title": "…"
}
| Field | Meaning |
|---|---|
triggerId |
the trigger that fired the run; the chat-watch id for a conversation; null for a control-API or embedder run |
device |
peer id of the device that ran it — the trace lives there |
startedAt / endedAt |
unix seconds |
status |
ok, FAILED, interrupted, or incomplete (no terminal cell record) |
errorType |
the failure's type, e.g. FuelExhausted |
mutations |
effects classified as writes |
tokens, costUsd, model |
LLM usage across the run |
The runs anchor is the bao/runs/v1 child, on the same hidden agent_trigger type as the trigger anchor, and its storage collection is namespaced like the triggers one. Read the last twenty fires of one trigger:
TRG=$(curl -s "http://127.0.0.1:7001/v1/spaces/$SPACE/types?includeHidden=true" \
| jq -er '.types[] | select(.xKey=="agent_trigger") | .id')
RUNS_ANCHOR=$(curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/bao%2Fv1/children \
-H 'Content-Type: application/json' \
-d "{\"seed\": \"bao/runs/v1\", \"type\": \"$TRG\"}" | jq -er .objectId)
RUNS=$(curl -s http://127.0.0.1:7001/v1/spaces/$SPACE/datasets \
| jq -r '.datasets[].name | select(endswith("_agent_runs"))')
curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/query \
-H 'Content-Type: application/json' \
-d "{\"objectId\": \"$RUNS_ANCHOR\", \"dataset\": \"$RUNS\",
\"filter\": {\"triggerId\": \"daily-digest\"}, \"sort\": [\"-startedAt\"], \"limit\": 20}" \
| jq '.records[] | {runId, startedAt, status, durationMs, errorType, device}'
runId opens the trace on the device named in device: anyrt trace show <runId> --addr http://127.0.0.1:7001 renders every effect, mutations marked. Trace bodies stay on that device; the summary is what syncs.
The stamp on the trigger
The trigger record carries only scheduler state, written by the owning device after each fire:
| Field | Meaning |
|---|---|
lastRunAt |
time of the last fire (unix seconds) — the once consumed guard and the cron anchor |
lastStatus |
ok / error / interrupted, or auto_disabled, or a health marker (below) |
consecutiveFailures |
the breaker count, reset to 0 by an ok run |
A run is interrupted when a hard break stopped it; a fuel exhaustion or the one-hour wall deadline ends it as error (the run summary says FAILED). How many times a trigger ran, what it cost and which run was last are questions for agent_runs, not for the record.
The circuit breaker
maxConsecutiveFailures (default 3): when consecutiveFailures reaches it, the trigger auto-disables — enabled: false, lastStatus: "auto_disabled", the failing runs in agent_runs. Re-enabling is a manual act, and it resets the counter and re-arms the schedule forward. A background program is budgeted per run, observable per query, and self-quarantining on repeated failure.
Health markers
An enabled trigger that can never fire is marked, not silent. Each tick the owning device stamps lastStatus on its own inert entries:
| Marker | Cause |
|---|---|
invalid_spec |
a cron expression with no next occurrence; a once without a numeric at; an event without dataset and objectId |
unsupported_source |
an event whose dataset is not chat_messages |
Markers clear once the definition is fixed and are overwritten by the first real run status. The ticker's other silent paths — overlays still syncing, a failed reconcile query — log on transitions, never per tick, so "why isn't it firing" is answerable from the record or one log line.
Why it matters. The records are the monitoring surface. Any client that can query a dataset — the desktop UI,
curl, a program on another device — sees the same summaries, offline, without a dashboard service. The trace behind arunIdis the full story when the summary isn't enough.
The control API
A running anyrt serve exposes a loopback control API (default 127.0.0.1:7010, [agent].control_port). It reads the live registry and writes trigger mutations through to the dataset — the record is the source of truth, and a registry-only edit would be reverted by the next reconcile tick. Errors answer 400 {"error": "…"}.
| Route | Effect |
|---|---|
GET /triggers |
every entry in this device's registry: {id, name, kind, owner, enabled, lastRunAt, lastStatus, consecutiveFailures, limits} |
GET /triggers/:id |
the full record |
GET /triggers/:id/runs |
the 20 newest agent_runs summaries for the trigger |
PATCH /triggers/:id |
{spec?, enabled?} — a new spec re-arms forward |
POST /triggers/:id/enable |
enable, reset the breaker, re-arm |
POST /triggers/:id/disable |
pause |
POST /run |
{program, args?} or {source, args?, program?} — a one-shot run through the serve resolver, returning {status, value, error, traceRef, durationMs, fuelUsed} |
POST /break/:runId |
{"hard": bool} — stop any run in flight; a soft break becomes hard after 20 s unless the run drains it |
GET /election |
{app, enabled, active, peerId, winner} |
GET /status |
the presence beat this device publishes next, plus beatSec |
curl -s http://127.0.0.1:7010/triggers | jq '.[] | {id, enabled, lastStatus, consecutiveFailures}'
curl -s -X POST http://127.0.0.1:7010/triggers/daily-digest/disable
curl -s -X PATCH http://127.0.0.1:7010/triggers/daily-digest \
-H 'Content-Type: application/json' -d '{"spec": {"cron": "0 9 * * 1-5"}}'
curl -s -X POST http://127.0.0.1:7010/run \
-H 'Content-Type: application/json' -d '{"program": "agent:rollup@v1", "args": {"space": "bao", "chatId": "…"}}'
Creating and deleting triggers is done on the record (see Scheduling); the control API operates on ids the reconcile has already adopted here. POST /run runs inline on the control thread and is not gated by the election — an explicit invocation, same as the CLI.
Reading a run's trace
A serve keeps trace bodies in its any server's local store, so the viewers take --addr:
anyrt trace ls --addr http://127.0.0.1:7001 --program rollup # runs of one program, newest first
anyrt trace show run_<id> --addr http://127.0.0.1:7001 # turns, cells, effects (* = mutate)
anyrt trace show run_<id> --addr http://127.0.0.1:7001 --stats # tokens, cache, cost per turn
anyrt trace stats --addr http://127.0.0.1:7001 # fuel/duration/token distributions
Cron runs typically outnumber conversations many times over — filter with --program. Details on the format and the viewers: Traces and replay.
Progress for long runs
A run that takes more than a few seconds reports through the progress@v1 tool — start / tick / done / fail — which rides the server's process registry and renders as a global bar in the UI; done(..., notify=…) posts the outcome into the chat under a trigger:<job> identity so the agent reports it. See Processes and Progress and UI.