--- title: Event schema description: The unified event shape every runtime is normalised into — fields, event types, session identity and runtime namespacing, and the extension points. keywords: agent event schema, unified agent telemetry format, agent event types, session id namespacing eyebrow: Data & storage --- # Event schema Twenty-seven runtimes, one event shape. Everything downstream — the dashboard, the rollups, the detectors, the MCP tools, the cloud snapshot — reads this and nothing else. ## An event ```json { "id": "…", "session_id": "claude_code:1f2e3d…", "agent_id": "…", "ts": 1788745200.123, "event_type": "tool_call", "role": "assistant", "tool_name": "Bash", "model": "claude-sonnet-5", "cost_usd": 0.0041, "data": { "…": "runtime-specific detail" } } ``` | Field | Notes | |---|---| | `id` | Deterministic. Re-ingesting the same source is a primary-key no-op, never a duplicate. | | `session_id` | Namespaced by runtime — see below | | `agent_id` | Which agent, where the runtime distinguishes them | | `ts` | Epoch seconds, float | | `event_type` | See the table below | | `role` | `user`, `assistant`, `system`, `tool` | | `tool_name` | Present on tool events | | `model` | Present on model calls. This is what makes cost derivable. | | `cost_usd` | Derived at ingest, or the vendor's figure. `null` when genuinely unknown. | | `data` | The runtime's own detail, preserved rather than flattened away | ## Event types | Type | Meaning | |---|---| | `session.started` | A session began | | `message` | A user or assistant turn | | `prompt.submitted` | A user prompt was submitted | | `model.completed` | A model call finished, with usage | | `tool_call` | A tool was invoked | | `tool_result` | A tool returned, success or failure | | `error` | The runtime recorded a failure | | `thinking` | A reasoning block, where persisted | | `compaction` | Context was compacted | | `session.ended` | A session ended | Runtimes emit richer vocabularies of their own; those are preserved in `data` and mapped onto these for anything cross-runtime. A detector that asks "was this a tool call" gets a consistent answer regardless of whether the runtime called it `function_call`, `toolRequest`, `ToolCall` or `tool.execution_start`. ## `data` is not flattened The runtime's own payload is kept. That is what makes the [Runtime extras](/docs/dashboard/developer/) tab possible — Grok's upload manifest, Cline's rejected tool calls, Kimi's context-window occupancy — none of which fits a unified schema and all of which is worth keeping. The daemon adds `data.extra.runtime` on ingest, so an event always knows which runtime produced it even after it has been through a rollup. ## Session identity Session ids are **namespaced by runtime**: ```text claude_code:1f2e3d4c-… codex:019e28c2-… cursor:composer-… pi- n8n- ``` Three consequences worth knowing: 1. **Filtering by runtime genuinely scopes a query** rather than filtering rows client-side, which is what keeps the runtime filter cheap on a large store. 2. **Ids do not collide across runtimes** even when the underlying values would. 3. **Synthesised child sessions carry a composite id** — `::` for a PicoClaw delegation, or `:vmusage:` for a hosted-VM usage row — so a synthetic session is identifiable as one. ## Spans OpenTelemetry-shaped spans are stored alongside events, for tracing and the agent graph. The mapping from a Claude Code transcript is the clearest example: | Source | Span | |---|---| | Assistant turn | `llm.call` | | `tool_use` block | `tool.`, child | | `tool_use` named `Task` | `agent.spawn`, child | | `thinking` block | `thinking`, kind `INTERNAL` | Spans also arrive directly over [OTLP](/docs/runtimes/opentelemetry/). ## Timestamps Every runtime is normalised to epoch seconds as a float. The sources vary enormously — ISO 8601 with and without a zone, epoch seconds, epoch milliseconds, epoch microseconds, local time with no zone at all, Go-trimmed RFC 3339 fractions — and each adapter converts. Where a record genuinely carries no timestamp, the adapter falls back to file mtime and offsets successive messages slightly so stream order is preserved. :::warning A session with no timestamp A `NULL` timestamp renders as "just now", forever. Adapters are written to avoid producing one; if you see a session that is perpetually current, that is the shape of the bug. ::: ## Cost fields | Field | Meaning | |---|---| | `cost_usd` | The figure, or `null` | | `cost_status` | `exact`, `estimated`, `partial`, `tokens_only`, `unavailable` | | `spend_basis` | What produced the figure | `cost_usd: null` with `cost_status: "unavailable"` means the data is not there. It does not mean zero. → [How cost is computed](/docs/cost/model/) ## Adding your own events The [ingest API](/docs/runtimes/custom-ingest/) accepts this shape directly, so anything you push participates in the same rollups, detectors and views as a first-class runtime.