From the open engine’s docs (traza 0.28.0). View source Engine routes reach a Traza Cloud project through the gateway.
Steer: live runs, nudges and topology
The run guard already follows every live agent run in memory — its usage, its verdict — and is asked before each step whether the step may run. Steering puts a person in that loop:
- Live runs — which runs are working right now, what step each is on, what it has spent, and its verdict, as a listing and as a stream.
- Nudges — words for a running agent, or a veto of its next step, delivered once, through the pre-call check the agent already makes before each step.
- Topology — which agents a run involved, who delegated to whom, who handed control to whom, and where two agents bounced it back and forth; and a fuse limit on that ping-pong.
Nothing here calls a model or judges anything. Live runs and nudges are guard state plus one durable log; the topology is computation over a run’s stored spans. For Claude Code, the hook pack wires all of it in with one command.
See it run
examples/mission-control/run.sh
runs simulated support agents against a real server, each asking the guard
before every step. It checks the claims this guide makes — foresight on the
live listing, the stream’s snapshot and rows, a run held by its history, a
nudge heard at the next check after an approval with lower odds, the kill
switch, a ping-pong run stopped at three handoff cycles, and a fork — then
keeps the agents running for the dashboard’s Live screen until Ctrl-C.
Live runs
curl http://127.0.0.1:8080/v1/runs/live
{"runs":[{"agent":"triage","cost_usd":0.0,"current":{"kind":"llm","name":"gpt-4o-mini","pending":false,"started_ns":1792000004000001000},"errors":0,"last_activity_ns":1792298718444962000,"pending_nudges":0,"policy":"support","reasons":[],"run":"sess-42","service":"support-app","started_ns":1792000000000000000,"steps":8,"tenant":"","tokens":1840,"verdict":"continue"}],"truncated":false,"window_ns":900000000000}
A run is live while its last activity — a span arriving, or a
pre-call check (a POST /v1/fuses/check naming the next step) — is within
--live-window-seconds (default 900). Runs are listed most recently
changed first: a change is activity, a verdict that moved, or a nudge
queued or delivered. Reading a run’s verdict — a check naming no next
step included — is neither, so a dashboard polling a run cannot keep it
live.
| Field | What it is |
|---|---|
agent |
The agent the run is: its first step’s agent name (gen_ai.agent.name and the other agent keys), else its first span’s service. |
current |
The step the run is on. When a pre-call check named its next step and no span has arrived since, that step, with pending: true and the server’s clock — the tool an agent is about to call. Otherwise the latest-starting recorded step, by the span’s clock. |
started_ns |
The run’s first span’s start. |
steps, cost_usd, tokens, errors, verdict, reasons, policy |
As the guard counts and judges them. |
pending_nudges |
Nudges queued and not yet delivered or expired. |
foresight |
How earlier runs of its agent that took the same path ended — a verdict, the success rate with its 95% interval, and how many runs that is — read from memory; see foresight on live runs. |
foresight_deferred |
true when the row lacks foresight only because the one-time read that would give it foresight was not made by this request (see below); absent otherwise. |
The listing is built from the guard’s live state alone — no store read,
except the one-time read that gives a run its foresight (below).
Runs are indexed by change, across tenants and per tenant; a listing walks
from the newest change, stops at its limit (default 100, at most 1,000) or
at the first change older than the window, and skips on the way runs that
changed within the window without activity in it (a verdict that moved, a
nudge) — so it reads at most every run the guard holds, never more. What the
view needs beside the guard’s own state — each live run’s agent name, its
earliest and latest step, the step a check named — is a few hundred bytes
per live run, under the guard’s --fuse-max-runs bound.
What it costs ingest: for each run a batch touches, the run is re-stamped — one removal and one insertion in each of the two change indexes and an atomic store, under the guard’s state lock the fold already holds — and each step span’s agent and label are read beside the fingerprint the guard already extracts (copied only when longer than 160 characters, to cut), the agent’s whole name digested once to tell it from others.
What it shows is what this process has seen. A run the server began
following after a restart lists with the usage seen since, exactly like
GET /v1/fuses/runs; asking for the run’s verdict rebuilds it whole. A run
with no span yet holds no guard state, so it is not listed until its first
span arrives, however often it checks. Stopped runs evicted from memory
(parked) are not live.
Foresight on live runs
Each live run carries its foresight:
"foresight": {"base_rate": 0.5444, "ci95": [0.0435, 0.2136],
"map_built_at_ns": 1792312576819863000, "p_success": 0.1,
"support": 50, "verdict": "likely_failure"}
— this run’s path so far was taken by 50 earlier runs of its agent, and 5 of
them succeeded, against 54% for the agent overall. It is counted from
history, never predicted by a model. The guard keeps every live run’s
behaviour path — its earliest 192 steps, 48 bytes each, inside the guard’s
span budget — and the tenant’s Fate Map is the
cached one the predictive fuses read, so the verdict costs a walk of the path
down the map: no store read. Verdicts are judged at the predictive fuses’
default support of 30 runs; with fewer, or no map cached yet, the verdict is
insufficient_history.
Two things put a run’s foresight off for a moment. A listing that finds no
map cached asks the background builder for one, so the first look at a
tenant shows no verdicts and the next ones, a build later, do. And a run the
guard has followed only since it came into memory — never asked about — may
be a tail of a longer run, so it is read whole from the store first, as a
verdict query reads it: at most 8 such runs per listing, newest first, once
each while they stay in memory. Those reads share the listing’s one
--query-deadline-ms budget — a stream update has one of its own — so a
listing spends one budget on them, not one per read: once it is spent no
read starts, and a read it cut short ends the reading. A run whose read
failed is not read again for 60 seconds (at most 1,024 runs are remembered
so; an erasure forgets them), so polling does not repeat a read that cannot
finish. Every row that needed a read this request did not make says so with
"foresight_deferred": true, and lists without foresight until a later
request reads it. Runs that check before every step (the hook pack, the SDK
guards) are already whole. --fate-cache-seconds 0 turns all of this off:
no paths are kept, and no row has foresight.
The stream
GET /v1/runs/live/stream is the same view as server-sent events: an
event: snapshot carrying the listing first (up to 256 runs), then event: run with one
run’s row each time that run changes, and a : tick comment after 15
seconds of quiet. When a run whose row the client holds leaves the view, the
stream says so with event: removed — {"run", "tenant", "reason"}, the
reason evicted (the guard let go of it to stay inside --fuse-max-runs,
its span budget, or after six idle hours; a stopped run is parked) or
expired (its last activity fell out of --live-window-seconds, sent within
about 200 ms of it doing so, with no other change needed) — so a client that
applies every event holds exactly the rows the listing has. Removals come
before the update’s run events: a run evicted and back again within one
update is removed, then sent anew. Updates are coalesced — at most one batch
per 250 ms, a run that changed several times appearing once with its latest
state. When more than 256 runs change at once, an erasure sweeps the view,
the client would hold more than 4,096 rows, or more than 1,024 runs were
evicted since the stream last looked, the stream sends a fresh snapshot
that replaces everything the client holds. Reconnecting starts from a
snapshot; there is no resume cursor.
event: snapshot
data: {"runs":[{"agent":"triage",…,"run":"swarm-1",…}],"truncated":false,"window_ns":900000000000}
event: run
data: {"agent":"triage",…,"current":{"kind":"tool","name":"refund_order","pending":true,"started_ns":1792297739658473000},…}
event: removed
data: {"reason":"expired","run":"swarm-0","tenant":""}
To tell its client what left, a stream remembers the rows it sent and has not removed — about 64 bytes each beside the run ids the guard already holds, at most 4,096 a stream — and the guard remembers the last 1,024 runs it evicted, for the streams still behind them. Both are memory only.
A stream follows the guard’s change feed by reading an atomic counter every
200 ms, taking no lock until something changed. One server serves at most 64
streams at once, and one scope — a tenant, or the every-tenant view of an
unbound credential — at most 8 of them, so one tenant’s credential cannot
take every slot; past either, the stream is refused with 409. Each holds
one connection.
Nudges
curl -X POST http://127.0.0.1:8080/v1/runs/sess-42/nudges \
-d '{"message": "Hold refunds over $500 for a human.", "kind": "block", "ttl_seconds": 600}'
A nudge is a message to one run, queued until the run’s next
pre-call check
— a POST /v1/fuses/check that names the step it is about to take
("next": {...}, {} for an LLM call; the hook pack and both SDKs always
send it). That check carries every pending nudge in a new nudges field —
[{"id", "kind", "message"}], in the order they were queued — and marks
them delivered, durably, before it answers. Each nudge is delivered at
most once.
{"evidence":[],"grant":null,"limits":{"max_cost_usd":2.0,"max_handoff_cycles":2},"nudges":[{"id":"nd_394a4ad29672c47e","kind":"advice","message":"Billing owns refunds; do not hand them back to triage."},{"id":"nd_e03d16ad2736feaa","kind":"block","message":"Hold refunds over $500 for a human."}],"policy":"support","reasons":["Hold refunds over $500 for a human."],"run":"sess-42","source":"rebuilt","tenant":"","tripped_at_ns":null,"tripped_by":null,"usage":{…},"verdict":"stop"}
advice— words for the agent. The verdict is the guard’s own.block— a veto. The check that delivers it answersstop, with the blocking messages as its first reasons (the guard’s own follow when it saidstoptoo). So does every other pre-call check of the run in the two seconds after (BLOCK_HOLD_MS), carrying the same block nudge again (sameid; it is still delivered once): an agent that issues a batch of tool calls checks them together, or one right after another as each refused call is skipped, and a veto must refuse the batch, not whichever call asked first. Nothing about the fuse changes: no trip, no event, no sticky stop, so after the hold the guard judges the run as usual. To stop a run until someone resets it, use the kill switch.
Only a pre-call check delivers. A check naming no next step is a verdict
read: it answers "nudges": [] and leaves every nudge pending, as the
verdict route (GET /v1/fuses/runs/{run}) and the MCP check_run_guard
tool do — so a monitoring script or a dashboard probing a run cannot take
the agent’s nudges. Any caller that sends next for the run does consume
them, ro tokens included (the agent’s own token is usually ro): give the
check’s credential only to the agent. There is no MCP tool to read or queue
nudges, deliberately — an agent hears nudges through its check, and
queueing one is an operator’s act.
Who can queue one. Creating a nudge needs rw — the same scope a
telemetry collector holds. So a nudge is operator text, not proof that a
person wrote it: the hook pack relays nudges as queued in Traza, and the
SDKs hand them to the caller (Verdict.nudges and on_nudges in Python,
verdict.nudges and onNudges in TypeScript) to frame as it sees fit.
Keep rw credentials away from anyone you would not let instruct your
agents.
GET /v1/runs/{run}/nudges lists a run’s nudges, each with status:
pending, delivered (with delivered_at_ns) or expired.
Durable, bounded, erasable
Nudges are written to steer.jsonl in the data directory — one fsynced line
per nudge and per delivery, through the same log machinery as
fuses.jsonl: a torn final
line is healed at startup, damage anywhere else refuses startup, a failed
append is cut back out, and the file is rewritten to the surviving state once
half its lines are superseded. A delivery that cannot be written fails the
check (503) and leaves every nudge pending. Like fuses.jsonl it lives
outside the generation layout: backups do not carry it.
| Bound | Value | Past it |
|---|---|---|
| Message | 1 to 4,096 bytes | 400 |
ttl_seconds |
1 to 604,800 (7 days); default 3,600 | 400; an expired nudge is never delivered |
| Pending nudges per run | 32 | 409 |
| Blocks one delivery’s hold vetoes with | 32 | the newest kept: those just delivered, then the latest still holding |
| Nudges kept per run | 64 | delivered and expired ones dropped, oldest first |
| What one tenant’s nudges cost | 4 MiB | its delivered and expired nudges are dropped, oldest first; when only pending ones (and holding blocks) are left, 409 |
| What every tenant’s nudges cost | 32 MiB | anyone’s delivered and expired nudges are dropped, oldest first; when only pending ones (and holding blocks) are left, 409 |
A nudge costs its message, id, run id and tenant plus 512 bytes for its
place in memory and its indexes — so a tenant holds a few hundred nudges at
the longest message, a few thousand short ones. Delivered and expired
nudges are history, kept for GET /v1/runs/{run}/nudges and dropped first;
pending ones are never dropped to make room, and neither is a delivered
block whose two-second hold is still in force — it is the veto the run’s
checks are being answered with, and dropping it would let the next call of
its batch through. A run’s 32 pending nudges and 32 holding blocks together
never exceed the 64 it keeps, so the per-run bound always holds; a budget
that only holding blocks keep full refuses with 409 for at most those two
seconds. The file holds at most about twice what memory does before it is
rewritten.
No check waits on another run’s nudges. The nudges in memory and the log on disk have separate locks: queueing, delivering, forgetting and rewriting hold the log’s across their write and fsync, and take the memory lock only for in-memory work (a rewrite, only to copy out what it will write). A pre-call check with nothing pending — almost every check — takes the memory lock for one lookup and answers; it never waits on a write, an fsync or a rewrite.
A run need not have started: a nudge can wait for its first check. An
erasure of a run’s session or trace, or of its
tenant, removes its nudges and rewrites steer.jsonl without them; while
such an erasure is pending, nudging the run is refused with 409. That
refusal is decided under the nudge log’s lock, which the erasure’s own
removal takes too, so a nudge queued while an erasure starts is either
refused or removed by it — never left behind.
Messages are operator text and are delivered to the agent as written. Keep personal data out of them as you would out of a kill-switch reason.
Topology
curl http://127.0.0.1:8080/v1/runs/sess-42/topology
The agent graph of one run, computed from its stored spans under the query deadline:
- Nodes are agents. A span’s agent is the agent it names; a span that
names none belongs to its parent’s agent when the parent ran in the same
service, and to its own service otherwise (a process boundary is an actor
of its own).
named: falsemarks a service standing in for an agent. - Edges are parent → child span relations whose spans belong to different agents — delegations — with their count, failed children, and first and last child start.
- Handoffs, the sequence and cycles come from the run’s
handoff sequence: its steps that name an agent, in step order, with
consecutive steps of one agent merged into one turn. A step naming no
agent does not move control; it is work inside whichever agent holds it.
A cycle is control returning to an agent straight from the agent it
handed off to — turns
A, B, A— counted per ordered pair.
{"cycles":[{"agents":["billing","triage"],"count":1,"example":{"span_id":"a3","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"},"first_ns":1792000004000000000,"last_ns":1792000004000000000},{"agents":["triage","billing"],"count":1,"example":{"span_id":"a2","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"},"first_ns":1792000003000000000,"last_ns":1792000003000000000}],"cycles_truncated":false,"edges":[{"count":2,"errors":0,"first_ns":1792000002000000000,"from":"support-app","last_ns":1792000004000000000,"to":"billing"},{"count":2,"errors":0,"first_ns":1792000001000000000,"from":"support-app","last_ns":1792000003000000000,"to":"triage"}],"edges_truncated":false,"handoffs":[{"count":2,"from":"triage","to":"billing"},{"count":1,"from":"billing","to":"triage"}],"handoffs_truncated":false,"nodes":[{"agent":"billing",…,"named":true,"spans":4,"steps":4,…},{"agent":"triage",…},{"agent":"support-app",…,"named":false,…}],"nodes_truncated":false,"run":"sess-42","sequence":["triage","billing","triage","billing"],"sequence_truncated":false,"spans_read":9,"tenant":"","truncated":false}
Why per ordered pair: a supervisor that sends work to one worker three times
in a row (S W S W S W S) has three (S, W) returns — control came back to
the supervisor three times — and two (W, S). Two agents bouncing a task
between them with neither finishing it pile up both. A supervisor fanning
out to different workers (S A S B S C S) returns once from each, and no
worker ever gets control straight back.
Bounded: at most 50,000 spans are read (the earliest, in step order —
truncated says so), and the answer keeps at most 256 nodes, 1,024 edges,
1,024 handoff pairs, 256 cycle pairs and 500 turns of the sequence — the
lists largest first, each with its own flag (nodes_truncated,
edges_truncated, handoffs_truncated, cycles_truncated,
sequence_truncated). Agents and services are told apart by their whole
names: two agents whose names share their first 160 characters are two
nodes, take turns, and make cycles, and an unnamed span in a service whose
name merely starts like its parent’s is its own actor. A name longer than
160 characters renders as its first 160, …#, and 16 hex digits of a
digest of the whole name (<first 160 characters>…#3f9c0a1b2c3d4e5f), so two cut names
still render apart; the guard’s stop reasons render them the same way.
Every pass
over the spans is linear or a sort, and each checks --query-deadline-ms
as it goes: past the deadline the request is refused with 400, not
answered late. The topology reads the store, so it works under
--no-fuses too.
Handoff cycles as a fuse limit
A fuse policy may set max_handoff_cycles: the run is
held to the largest cycle count among its agent pairs, count >= limit
reaching it, exactly as the topology counts — but since the run’s last
reset, the way repeats are. --fuse-max-handoff-cycles N sets it on the
global policy.
curl -X POST http://127.0.0.1:8080/v1/fuses/policies -d '{
"name": "support", "match": {"service": "support-app"},
"limits": {"max_handoff_cycles": 2, "max_cost_usd": 2.0}, "action": "stop"}'
The acknowledgement of the step that made the second return to triage:
{"accepted":1,"durability":"wal","fuses":[{"reasons":["max_handoff_cycles 2 reached: control went triage → billing → triage 2 times"],"run":"sess-42","tenant":"","verdict":"stop"}]}
The verdict’s usage gains top_handoff_cycle —
{"agents": ["triage", "billing"], "count": 2}, the agent control returned
to first — whenever a cycle exists, and omits it otherwise. A run whose steps
name no agent holds no handoff state at all and never reaches the limit.
The guard keeps the sequence incrementally, exact under steps arriving in any order and re-sent spans that move or rename a step: a change can only alter the turns beside it, so each is recounted over a window of at most nine turns, in O(log n). A rebuild replays the same fold, and a randomized test holds it to a full recount of the store after every batch.
What it costs: about a hundred bytes of guard memory per named step beside
its span contribution — every named step is a span the guard’s
1,000,000-contribution bound already counts — plus the rendered name of
each distinct agent a held step names, up to 160 characters and the digest
of a cut one, at most 256 names a run. A name is dropped when the last step
naming it is, so a run never holds more names than named steps; a span
re-sent under ever new agent names holds one. Past 256 distinct agents, the
rest render as agent#<hex>. Agents are told apart by a 64-bit digest of
the whole name under keys drawn at random when the server starts, so nobody
writing telemetry can choose two names the guard confuses; when two pairs
tie for the most cycles, which one a stop names may differ after a restart,
the count never.
Tenancy
Live runs, nudges and topologies are per tenant, under the guard’s rules: a
bound credential reads and writes only its own tenant, and naming another is
403 {"error":"this credential is bound to a different tenant","required":"unbound"}.
The same run id in two tenants is two runs with two sets of nudges. An
unbound credential naming no tenant addresses the default tenant on the
single-run routes and every tenant on the live listing and stream.
Queueing a nudge needs rw. Everything else here needs only ro — and
POST /v1/fuses/check, which delivers nudges, stays ro: the agent’s
read-only token is what receives them.
MCP
Two read tools, both rendering telemetry inside the untrusted-content frame (see MCP):
run_topology— a run’s agents, delegations, ping-pong pairs and turns. When a list was cut, the headline says so (more than 256 agents, and which lists show only their largest entries). The headline is the server’s own words and counts; the run id and tenant, which whoever wrote the telemetry chose, are the frame’s first row.list_live_runs— the live listing; listed only when the guard is on.
Limitations
- Delivery is at most once, and needs a check. A nudge is marked delivered when the check carrying it answers; an answer lost on the way (a timeout, a crashed client) loses the nudge with it, though it still lists as delivered. An agent that never checks never hears its nudges, and one that is not taking steps hears them only at its next one.
- A veto blocks a two-second window, not a turn. A
blockrefuses every pre-call check of the run for two seconds after it is delivered: the calls of a batch checked together or back to back. A later call of the same batch that waits behind a long-running one gets past it, and an agent that tries again within the window meets the same veto. Traza sees no model turns, only checks. Use the kill switch to stop the run. - A nudge’s author is a credential, not a person. Any
rwcredential can queue one, and its text reaches the agent as written. - The live view is per process and partial after a restart. It is built from memory; a run followed since some point shows what arrived since, and live state is never shared across replicas.
- The stream polls. Changes and removals reach a stream within about 200 ms, plus the 250 ms coalescing; a stream has no resume cursor.
- Handoff cycles count named agents only. A run whose agents are told
apart only by service name gets service nodes and delegation edges in its
topology but no turns, no cycles, and nothing for
max_handoff_cyclesto count. Name agents withgen_ai.agent.name. - Across batches, arrival order counts — as it does for the error streak. The guard places a late-arriving named step where it started, so its counts always equal a recount of what it holds, but it judges the limit as steps land: a step that arrives after later-starting steps can latch a stop on a cycle that a step still in flight would have broken. With steps arriving in start order the live latch and a replay’s agree exactly.
- The guard counts since the last reset; the topology counts the whole run. After a reset, the two differ by design.