Tracing and inspection
Explain a turn using ordered events, child records, and its final outcome.
admit → passlookup → refusedtool-input-invalidcorrected lookup → resultcompletedThe final answer rarely explains why a tool was refused or a fallback ran.
Keel records those decisions as events, with a turnId that ties them to the
turn that made them. Start from the outcome, then trace the decisions that
produced it.
Follow the evidence
| Event kind | What it records |
|---|---|
gate | A gate's verdict and supporting details. |
assemble, render | Context sources, reader output, estimates, and clipping. |
reduce | A state update landing on a block — event, version, and the value after. |
tool | Calls, results, progress, refused input, filtering, caching, and execution errors. |
chain | Model attempts, retries, fallback, and exhaustion. |
defect, warn | A failure condition or warning encountered during execution. |
say | A part emitted on that turn's wire. |
outcome | The final outcome and completed turn record. |
Events have an ordered seq, a timestamp at from the runtime's injectable
clock, and a wall-clock timestamp wall. The bus retains the last busLimit
events (default 10 000, set in RuntimeConfig); older events roll off, so a
long session's export is a window, not an archive.
Inspect events in code
const unsubscribe = runtime.bus.subscribe((event) => {
if (event.kind === "defect") {
console.log(event.turnId, event.defect.code);
}
});
try {
await runtime.runTurn({
agent: assistant,
ambient,
envelope,
budget: { steps: 8 },
});
} finally {
unsubscribe();
}
const records = await runtime.store.records();
const traceJson = runtime.bus.export();Store reads are async — records() and listMessages() return promises, so
await them. Keep subscribers lightweight: a bus subscriber that throws is
contained (a console.warn, and the turn continues), because observability
must never take the turn down. The onEvent hook is held to a higher
standard — a throwing hook surfaces as a hook-failure defect on the bus,
though the turn still continues. The bus export contains the session's
retained events, not just the latest turn; filter runtime.events by
turnId when inspecting a single invocation.
Read recovered failures correctly
A tool-input-invalid defect followed by a corrected call can belong to a
completed turn. Conversely, text emitted before a timeout can belong to a
cut turn. Even a gate can speak twice on one turn: the close gate logs
refuse when a required deliverable is missing, then pass after a nudge
round recovers it. Neither a defect alone nor visible output alone determines
the outcome.
Read the final record's outcome and outcomeDetail, then inspect the earlier
events for the cause. Billing fields record Keel's decision; they do not
execute a refund through an external payment provider.
Follow delegation without mixing wires
Each child has its own turn ID and a record whose parentTurnId points to its
caller. Follow that relationship to reconstruct nested work. Child events
remain observable even when forwarding is disabled.
The user's output stream comes from the root's wire hook. A session-wide
runtime.events list contains child events too; do not treat that entire
list as output the root user received. Model usage is recorded per turn;
children have separate records rather than being included automatically in
the parent's token total.
Inspect in Studio
Select the failing turn in TURNS first. Use CONTEXT for input size and tool visibility, MODEL for calls and fallback, STATE for reads and updates, and WIRE for that turn's emitted parts.
For an unexpected result, reproduce the run with a fake tier behind the
public ModelAdapter interface, find the first relevant refusal or defect,
and verify the final outcome. Export the session when sharing the
reproduction, and keep the reproduction as a behavior test so the fix stays
fixed.
