Event schema
Event kinds, payloads, the canonical CBOR wire format, and the invariants the runtime enforces.
Wire-format reference for the event log.
Envelope
Every event is wrapped in the same struct:
type Event struct {
RunID string `cbor:"run_id"` // ULID, per-run identifier
Seq uint64 `cbor:"seq"` // monotonic per run, starts at 1
PrevHash []byte `cbor:"prev_hash"` // BLAKE3 of canonical CBOR of prev event
Timestamp int64 `cbor:"ts"` // unix nanoseconds (from step.Now)
Kind Kind `cbor:"kind"` // discriminator
Payload cborenc.RawMessage `cbor:"payload"` // kind-specific struct, CBOR-encoded
}Hash chain: ev.PrevHash = BLAKE3(CanonicalCBOR(prevEvent)). The first
event has empty PrevHash. Canonical CBOR follows RFC 8949 §4.2:
shortest integer form, sorted map keys, no indefinite-length items,
shortest float that round-trips.
Event kinds
Closed set. Adding a kind is a schema-version bump.
Emitted by the runtime today
| # | Kind | Emitted by |
|---|---|---|
| 1 | RunStarted | First event of every run. |
| 2 | UserMessageAppended | Resume injecting an extra message. |
| 3 | TurnStarted | step.LLMCall pre-call. |
| 4 | ReasoningEmitted | Provider reasoning blocks (optional). |
| 5 | AssistantMessageCompleted | step.LLMCall on ChunkEnd. |
| 6 | ToolCallScheduled | step.CallTool / CallTools pre-dispatch. |
| 7 | ToolCallCompleted | Tool returned successfully. |
| 8 | ToolCallFailed | Tool returned an error. |
| 9 | SideEffectRecorded | step.Now, Random, SideEffect. |
| 10 | BudgetExceeded | Budget axis tripped. |
| 12 | RunCompleted | Terminal: successful run. |
| 13 | RunFailed | Terminal: error path. |
| 14 | RunCancelled | Terminal: ctx cancelled. |
| 15 | RunResumed | Non-terminal seam from Resume. |
Reserved (defined in schema, not emitted by core)
| # | Kind | Notes |
|---|---|---|
| 11 | ContextTruncated | Reserved for context-window trim strategies. Validate accepts; core does not emit. |
| 16 | TurnFailed | Reserved for mid-turn streaming failure with retry. Validate accepts; core does not emit. |
Payloads
Payloads are CBOR-encoded structs. The highlights:
RunStarted (kind 1)
Pins the entire deterministic surface of the run.
type RunStarted struct {
SchemaVersion uint32
Goal string
ProviderID string
ModelID string
APIVersion string
ParamsHash []byte
Params cborenc.RawMessage
SystemPromptHash []byte
SystemPrompt string
ToolRegistryHash []byte
ToolSchemas []ToolSchemaRef
Budget *BudgetLimits
StarlingVersion string // linked module version
AppVersion string // caller-supplied
}TurnStarted / AssistantMessageCompleted (kinds 3, 5)
type TurnStarted struct {
TurnID string
PromptHash []byte
InputTokens int64
}
type AssistantMessageCompleted struct {
TurnID string
Text string
ToolUses []PlannedToolUse
StopReason string
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheCreateTokens int64
CostUSD float64
RawResponseHash []byte // BLAKE3 of canonicalized provider response
ProviderRequestID string
}ReasoningEmitted (kind 4)
Optional. Anthropic-only fields are populated only when the provider returned them; OpenAI reasoning summaries arrive without a signature.
type ReasoningEmitted struct {
TurnID string
Content string
Sensitive bool
Signature []byte // Anthropic per-block integrity signature
Redacted bool // true when Content is the opaque redacted_thinking payload
}ToolCallScheduled / Completed / Failed (kinds 6, 7, 8)
type ToolCallScheduled struct {
CallID string
TurnID string
ToolName string
Args cborenc.RawMessage
Attempt uint32
IdempKey string
}
type ToolCallCompleted struct {
CallID string
Result cborenc.RawMessage
DurationMs int64
Attempt uint32
}
type ToolCallFailed struct {
CallID string
Error string
ErrorType string // "timeout" | "panic" | "tool" | "cancelled"
DurationMs int64
Attempt uint32
}Retries share CallID with incrementing Attempt.
SideEffectRecorded (kind 9)
Captures any non-deterministic value the agent loop consumed. step.Now
emits one with Name: "now"; step.Random with "rand"; user calls
to step.SideEffect set their own name.
type SideEffectRecorded struct {
Name string
Value cborenc.RawMessage
}BudgetExceeded (kind 10)
type BudgetExceeded struct {
Limit string // "input_tokens" | "output_tokens" | "usd" | "wall_clock"
Cap float64
Actual float64
Where string // "pre_call" | "mid_stream" | "post_call"
TurnID string
CallID string
PartialText string
PartialTokens int64
}RunCompleted / Failed / Cancelled (kinds 12, 13, 14)
All three terminals carry a MerkleRoot []byte over the BLAKE3 hashes
of every prior event. RunCompleted adds totals (turn count, tool-call
count, USD, tokens, duration). RunFailed adds an error string and
classification. RunCancelled adds a reason.
RunResumed (kind 15)
type RunResumed struct {
AtSeq uint64 // last seq from the prior process
ExtraMessage string
ReissueTools bool
PendingCalls int
}A seam, not a terminal. Marks the boundary between a crashed run's prior process and its resuming process. Validation pairing rules treat it as a reset point: orphaned tool-call schedules from before the seam don't need outcomes after it.
Invariants
eventlog.Validate enforces:
- Slice non-empty,
events[0].Seq == 1, monotonic seq with no gaps. RunIDconsistent and non-empty across all events.- Hash chain unbroken: each
PrevHashequals BLAKE3 of canonical CBOR of the previous event. - Exactly one terminal event, and it's the last event.
- First event is
RunStartedwithSchemaVersion ∈ [1, current]. - Turn pairing. Every
TurnStartedis closed by a same-TurnIDAssistantMessageCompletedorBudgetExceededbefore the nextTurnStarted. An open turn at the terminal is allowed only when the terminal isRunFailedorRunCancelled. - Call pairing. Every
ToolCallScheduledhas exactly one matchingToolCallCompletedorToolCallFailedwith the same(CallID, Attempt). Outcomes without a prior schedule, or duplicate outcomes for the same key, are rejected. ARunResumedseam clears pending pairing state. - Merkle root in the terminal payload matches the recomputed BLAKE3 Merkle root over every pre-terminal event.
Invalid log → ErrLogCorrupt with a diagnostic locating the violation.
Worked example
A run with one LLM turn, two parallel tool calls, then a final summary turn:
seq=1 RunStarted prev=nil
seq=2 TurnStarted prev=H(seq1) turn=T1
seq=3 AssistantMessageCompleted prev=H(seq2) turn=T1, tool_uses=[C1, C2]
seq=4 ToolCallScheduled prev=H(seq3) call=C1, attempt=1
seq=5 ToolCallScheduled prev=H(seq4) call=C2, attempt=1
seq=6 ToolCallCompleted prev=H(seq5) call=C2, attempt=1
seq=7 ToolCallCompleted prev=H(seq6) call=C1, attempt=1
seq=8 TurnStarted prev=H(seq7) turn=T2
seq=9 AssistantMessageCompleted prev=H(seq8) turn=T2, no tool_uses
seq=10 RunCompleted prev=H(seq9) merkle_root=M(seq1..seq9)Parallel tool completions land in arrival order: replay reproduces that ordering deterministically because results are read from the log, not re-executed.
Size expectations
Order of magnitude per event:
| Kind | Typical size |
|---|---|
RunStarted | 1–10 KB |
TurnStarted | under 1 KB |
AssistantMessageCompleted | 2–50 KB |
ToolCallScheduled/Completed | 1–20 KB each |
| Terminals | under 1 KB |
A typical 5-turn run with 10 tool calls is ~100–500 KB. The retention patterns are documented under Operations.
Schema evolution
Pre-1.0: any change permitted, schema-version bump on breaks. After 1.0:
- Additive (new optional fields, new kinds) → minor bump.
- Breaking → major bump.
- Replayer refuses logs whose schema version is newer than its own major.
- Old logs remain replayable forever: pin the binary version, or use the migration tools shipped at the major bump.