Event schema
The canonical event is the single vocabulary everything in teaspill speaks: both harnesses emit it, the platform appends it to an entity's timeline, and the frontend SDK materializes it. This page is the field-by-field specification; for the mental model, read Timelines & events.
The schema is defined in @teaspill/schema (events.ts and deltas.ts).
v and ships a migration.Envelope
Every timeline event is { v, entityId, seq, ts, type, payload }.
| Field | Type | Meaning |
|---|---|---|
v | 1 (literal) | Schema version. Bumps only on a breaking change. |
entityId | string | Canonical entity URL (/t/<tenant>/a/<type>/<id> — see Addressing). |
seq | int ≥ 0 | 0-based, gapless, monotonic per entity. The ordering authority. |
ts | ISO 8601 (with offset) | Informational timestamp. Ordering authority is seq, not ts. |
type | one of 15 | Discriminant (the table below). |
payload | per-type | Shape depends on type. |
The seq guarantees
seq is the backbone of the timeline. Three rules hold for every entity:
- The first event has
seq === 0and is alwaysentity_spawned. - Every event occupies a slot: a
state_snapshotat seq N consumes N like any other event. Nothing skips a number. seqis assigned server-side by the one component that owns the entity's history, so numbering is gapless by construction. Producers never pick their ownseq.
Because seq is 0-based and gapless, a reader can always answer "did I miss anything?" — a gap is drift, never normal. A snapshot at seq N lets a reader join late and consume from N+1 without replaying earlier events.
seq. They ride a sibling stream (see Token deltas).The fifteen event types
| Type | Purpose |
|---|---|
entity_spawned | Always seq 0. The entity's first event. |
run_started | A wake began. Carries runId, wake source, harness kind, model. |
message | A finalized conversation message (user / assistant / system_note). |
tool_call | The model invoked a tool. |
tool_result | A tool returned or errored. |
reasoning | Finalized thinking. Display-only — never re-projected into provider context. |
state_snapshot | The complete entity state as of this event's seq (inclusive). |
summarization | A context-truncation boundary. |
control | A control verb was applied. |
error | An error occurred. |
run_finished | A wake ended. Carries outcome and token usage. |
child_spawned | This entity spawned a child. |
child_finished | A child reported completion back to this entity. |
archived | Episode-terminal archive marker. Not globally terminal. |
opaque | A lossless carrier for foreign records with no canonical home. |
Each subsection below gives the payload shape and the notes you need to consume it.
entity_spawned
{ entityType: string, parentId: string | null, spawnArgs?: Json, workspaceRef?: string }
Always seq 0. parentId is the parent entity URL, or null for a root entity. workspaceRef is the workspace key (<tenant>/<name>) chosen at spawn and never switched afterward.
run_started
{
runId: string,
wake: { source: WakeSource, from?: string },
harness: "native" | "casdk",
model?: string,
detail?: Json,
}
runId is unique per wake; every event the run produces carries it. wake.source is one of spawn, message, steer_degraded, cron, control, system. wake.from is the sender entity URL when the wake came from another entity. detail carries harness-specific extras (informational only).
message
{ id: string, runId?: string, role: "user" | "assistant" | "system_note", content: ContentBlock[], from?: string }
id is the stable message id that token deltas reference. system_note is a platform-authored annotation (for example, "child x finished") — it is context-bearing but rendered to providers as a marked user message, never the API-level system prompt. from is the sender entity URL for inter-agent messages.
tool_call
{ runId: string, toolUseId: string, name: string, input: Json }
toolUseId is the provider tool-use id — the third component of the tool idempotency key (entityId, runId, toolUseId) that makes tool execution exactly-once.
tool_result
{ runId: string, toolUseId: string, name?: string, content: ContentBlock[], detail?: Json, isError: boolean }
detail is structured result data (diff, exit code, a stream reference, …) for rich renderers; it is populated by the in-process tool layer.
reasoning
{ id: string, runId?: string, text: string, encrypted?: string }
Display-only history. Reasoning is never projected back into provider context (thinking signatures are unforgeable), and context assembly skips it. encrypted carries an opaque redacted-thinking payload when the provider supplies one.
state_snapshot
{ state: Json, reason: "periodic" | "pre_archive" | "recovery", historyHole?: boolean }
A snapshot at seq N asserts: state is the complete materialization of this entity after consuming every event with seq <= N. To fast-join, initialize from state and consume from N+1. historyHole: true marks a recovery snapshot after catastrophic stream loss — a marked gap where earlier events may be missing; consumers must not gap-check across it.
summarization
{ runId?: string, summary: string, replacesThroughSeq: number, detail?: Json }
A context-truncation boundary. For context assembly, context-bearing events with seq <= replacesThroughSeq are superseded by summary. replacesThroughSeq is always less than the event's own seq. It does not delete or compact the stream — history stays intact for UIs; only the model-facing projection folds.
control
{ verb: "interrupt" | "pause" | "resume" | "archive", reason?: string, from?: string }
Records that a control verb was applied. from is the requesting principal or entity when known.
error
{ runId?: string, code?: string, message: string, source: "harness" | "tool" | "platform" | "provider" | "projection", detail?: Json }
code is a stable machine-readable code when available; message is always present.
run_finished
{ runId: string, outcome: "success" | "error" | "interrupted", usage: RunUsage, durationMs?: number, detail?: Json }
Ends a wake. usage (below) is the authoritative token accounting for the run.
child_spawned / child_finished
// child_spawned
{ childId: string, childType: string, runId?: string, toolUseId?: string }
// child_finished
{ childId: string, outcome: "success" | "error" | "interrupted" | "archived", result?: Json }
child_finished arrives on a later wake of the parent — the golden rule of multi-agent coordination. result is whatever the child reported back.
archived
{ reason: "idle" | "requested", snapshotSeq?: number }
The episode-terminal marker of an archive. A state_snapshot with reason: "pre_archive" immediately precedes it. Not globally terminal: sending to an archived entity resurrects it — the same identity continues the same seq counter, and the next event is the resurrecting wake's run_started.
opaque
{ origin: string, kind: string, data: Json }
A lossless carrier for foreign records with no clean canonical home — unknown provider artifacts, records from another agent runtime. data round-trips verbatim; every consumer that does not recognize the origin may ignore it.
Shared fragments
ContentBlock
Message and tool-result content is deliberately small — text and image only for v1. Anything richer a tool wants to convey goes in tool_result.detail.
{ type: "text", text: string }
{ type: "image", mimeType: string, data: string /* base64 */ }
RunUsage
{
inputTokens: number, // uncached input: fresh prompt + cache writes; cache reads excluded
cacheReadTokens?: number, // tokens read from the prompt cache
outputTokens: number, // completion tokens
contextTokens?: number, // cache-inclusive prompt size of the last step ("% of context used")
steps?: number,
costUsd?: number,
attempt?: number, // retry attempt; keep the latest attempt's numbers only
}
Token deltas
Token deltas are the live, streaming fragments of a message while the model is still producing it. They ride a sibling /deltas stream, never the timeline, and are always superseded by the finalized event.
// common fields
{ v: 1, entityId: string, runId: string, ref: string, idx: number, ts: string, attempt?: number, kind, ... }
Contract:
- Deltas carry no
seq. Each references the canonical event it streams toward viaref— themessageid, thereasoningid, or thetoolUseIdthe finalized event will carry. idxis a per-refchunk counter for UI assembly. Gaps are allowed — dropped chunks are normal, not drift.- The finalized event always wins. Once the timeline carries the finalized event with
id == ref, buffered deltas for that ref are discarded. Deltas are never used to reconstruct state. attemptdistinguishes retry attempts of the same run; render the highest attempt and drop the rest.
The four delta kinds (discriminated on kind):
| Kind | ref is | Extra fields |
|---|---|---|
text | message id | text |
reasoning | reasoning id | text |
tool_input | toolUseId | text (partial JSON) |
usage | runId | usage (partial RunUsage) — best-effort mid-run counters; the authoritative figure is run_finished.usage. |
Helpers
Exported from @teaspill/schema.
| Function | Purpose |
|---|---|
parseTimelineEvent / safeParseTimelineEvent | Parse an unknown value into a canonical event (throwing / non-throwing). |
parseTimelineEventJson | Parse one JSON-encoded stream record. |
isTimelineEvent | Type guard. |
finalizeEvent(init, { entityId, seq }) | Stamp v/entityId/seq onto a produced event and validate it. |
checkSeqContiguity(events, { expectedFirstSeq }) | The gap detector. A reader joining from a snapshot at seq N passes N + 1. |
checkTimelineInvariants(events) | Structural checks beyond per-event shape (first event is entity_spawned, summarization bound). |
parseDeltaRecord / safeParseDeltaRecord | Parse a delta record. |
EVENT_TYPES / DELTA_KINDS | The vocabularies as string arrays. |
Backup & restore
What each store holds, how to capture a consistent backup, and exactly which restore combinations come back cleanly versus lossily — and why.
Addressing
Entity URLs, the gateway short form, stream paths, workspace keys, the segment grammar, and the addressing helpers in @teaspill/schema.