Skip to content

The event model

Everything the engine reasons about is a sequence of these. Get this right and every rule, verdict, artifact and analysis follows.

type TraceEvent = {
runId: string
seq: number
ts: number
kind: 'tool.call' | 'tool.result' | 'step.start' | 'step.finish'
| 'run.suspend' | 'run.resume' | 'run.finish' | 'agent.message'
name?: string
payload?: unknown
meta?: Record<string, unknown>
}
Kind Means name
tool.call A tool is about to run. The tool’s id.
tool.result It returned. The tool’s id.
step.start A workflow step is about to run. The step’s id.
step.finish It returned. The step’s id.
run.suspend The run paused. —
run.resume The run continued. —
run.finish The run ended. payload.status carries how. —
agent.message The model said something. —

seq is the clock. It starts at zero, advances by exactly one per committed event, and never restarts within a run. Every temporal operator counts in seq, so a hole or a repeat changes what rules mean. This is why the counter advances on commit rather than on construction: an event that is drafted and then refused never happened, and must not consume a number.

ts is for humans. Nothing in the engine reads it. It is what the demo shows as elapsed time.

A call and its result are separate events. tool.call is emitted before the tool runs, tool.result after it returns. A tool that throws produces a call with no result — which is exactly what a response rule is watching for, so do not synthesise one.

Order is causal, not wall-clock. Concurrent work still gets a total order, and the guard serialises the drafting of events for exactly that reason. What matters is that a result never precedes its call.

payload is whatever the tool was given or returned. It must be JSON-serializable if the trace is going to be recorded. Field matchers read into it with dotted paths, and a missing field, a non-object payload, or a value of the wrong type all read as “does not match” rather than throwing.

meta is for the adapter. The one key the engine cares about is meta.payloadHash, attached to tool.call and step.start: a stable digest of the arguments, which is what repetition checks key on. Compute it where the validated arguments are in hand.

The most important consequence of the model. A guard drafts a candidate, asks the monitor what it would mean, and commits only if the call runs. So a denied or suspended call contributes nothing: no event, no seq, no budget, no repetition.

That is right, and an adapter must preserve it. A trace is a record of what happened, and a call that was refused did not happen.

The demo draws that decision even though the trace cannot: a refused call turns the path it reached for red. That is the one thing a graph can show you which no trace can, and it is why the picture exists.

The kind list is closed in the engine’s types but open in a matcher: Matcher['kind'] is string, not the union. A rule set written against a newer adapter must still load on an older engine, matching nothing rather than failing to parse. Loading such a rule produces an unknown-event-kind lint warning, never an error.

So an adapter may emit a kind the engine has not heard of. Nothing will match it, and nothing will break.