The trace schema
Every step your agent takes is rows in your database — transactional, priced, and joinable to the business rows the agent touched. Most frameworks treat their execution record as internal bookkeeping in a store beside your app. Silas treats it as a documented, versioned interface: your analytics, your dashboards, and your own tooling are meant to query these tables directly.
That is the whole point of the trace living where it does. An external
observability platform can show you what the agent said; it cannot join the
tool call to the refunds row it created, in one query, with no export:
SELECT ti.tool_name, ti.arguments, ti.approved_by, r.amount_pence
FROM silas_tool_invocations ti
JOIN refunds r ON r.id = json_extract(ti.result, '$.refund_id')
WHERE ti.approval_state = 'approved';
The contract
Columns documented here are stable within a major version: they may gain siblings in any release, but renames and semantic changes only happen at a major, and appear in the CHANGELOG under an upgrade note. Columns not listed here are internal — query them if you like, but they can move without notice.
silas_sessions — a conversation
| Column | Meaning |
|---|---|
agent_name |
Which agent owns the session ("agent" = the root agent). |
status |
active | archived. Note: parked work is a turn state, not a session state. |
parent_session_id |
Set when this session was created by a handoff or delegation — the lineage the inbox renders. |
channel |
The transport that started it ("slack", "email", …), NULL for direct/API. |
metadata |
JSON. Channel-specific context (e.g. the inbound email’s sender). Plain json, not jsonb — the schema works identically on SQLite and Postgres. |
silas_turns — one request, run durably
| Column | Meaning |
|---|---|
index |
Position within the session; (session_id, index) is unique. |
status |
queued | running | waiting (parked for a human) | in_doubt | completed | failed | canceled. At most one non-final turn per session — enforced by index_silas_turns_single_active. |
input |
What the user (or channel, or schedule) asked. |
instructions_snapshot |
The system prompt as rendered once at turn start; immutable after. What the model actually saw, forever. |
definitions_snapshot |
{"tools" => [schemas], "final_answer" => schema-or-nil} — everything else the model saw, stamped once beside the instructions. Resume reads this, not the live registry, so a park survives a deploy. NULL on pre-snapshot rows. |
definitions_digest |
Hash of tool schemas + skill descriptions at turn start. With a snapshot present, a mismatch is instrumented as drift; on pre-snapshot rows it refuses resume against a changed agent. |
failure_reason |
Why a failed turn failed (max_steps, definitions_changed, job_failed, budget reasons, …). |
input_tokens / output_tokens / cost_microcents |
Accumulated across the turn’s steps. 1,000,000 microcents = $1. |
budget_overrides |
JSON. A human’s top-up on a budget-parked turn, beating agent.yml limits. |
started_at / finished_at |
Wall-clock bounds. Note started_at resets on resume after a park — limits.timeout measures active time, not human deliberation. |
silas_steps — one model call
| Column | Meaning |
|---|---|
index |
Position within the turn; (turn_id, index) is unique — at most one persisted model response per slot. |
status |
started | completed. |
model / provider |
What actually served this step. Provider is stamped at execution so historical cost survives registry changes. |
response_blocks |
The model’s full response — text, tool calls — as JSON. Replay rebuilds the conversation from these rows. |
stop_reason |
Why the model stopped (tool_use, end_turn, …). |
terminal |
Write-once after completion; the loop-control column. A resumed continuation must re-derive the identical step sequence from it. |
input_tokens / output_tokens |
This step’s usage; cost derives from these plus (model, provider) pricing at read time. |
silas_tool_invocations — one tool call, exactly once
The heart of the ledger.
| Column | Meaning |
|---|---|
tool_call_id |
The model’s id for the call. (step_id, tool_call_id) is unique — this index is the exactly-once key. |
tool_name / arguments |
What was called, with what. Arguments are model-authored — render escaped. |
status |
pending | started | completed | failed | in_doubt. |
effect_mode |
transactional | at_most_once | idempotent — snapshotted from the tool class at creation, so editing a tool mid-park cannot change the semantics of an existing invocation. |
result |
The tool’s return value (or the question’s answer, or {"denied": reason}). |
approval_state |
NULL (no gate) | required | approved | declined | expired. NULL on a completed invocation means policy cleared it; a value means a decision was made — that distinction is load-bearing for audit. |
approved_by |
Who decided. NULL on an auto-cleared gate, an identity string on a human verdict. |
approval_expires_at / decline_reason |
The TTL, and the human’s stated reason. |
The free labeled dataset. approval_state + approved_by +
decline_reason record a human’s verdict on a specific agent action, with
full context, in production, at zero annotation cost. If you ever train or
evaluate against your own traffic, this is where the labels already are.
silas_compactions — summaries that survive replay
One row per compacted span: session_id, up_to_turn_index (unique
together — exactly one summary per span no matter how many replays race),
status, summary, and what the summarisation itself cost (tokens_before,
input_tokens, output_tokens, model). Deterministic by construction: the
message builder reads the row, never recomputes.
silas_memories — what the agent knows across sessions
agent_name, scope (agent private | app shared), subject,
attribute_name, content, status (active | superseded),
superseded_by_id, and provenance (session_id, turn_id). Approval-gated
on write by default. Single-tenant per deployment — see
guarantees for the boundary.
Queries you already own
Cost per agent per day:
SELECT s.agent_name, date(t.created_at) AS day, SUM(t.cost_microcents)/1e6 AS dollars
FROM silas_turns t JOIN silas_sessions s ON s.id = t.session_id
GROUP BY 1, 2 ORDER BY 2 DESC;
Every action a specific person approved, with what it did:
SELECT ti.created_at, ti.tool_name, ti.arguments, ti.result
FROM silas_tool_invocations ti
WHERE ti.approved_by = 'dana@example.com' ORDER BY ti.created_at DESC;
What parked, and for how long, before a human answered:
SELECT ti.tool_name, ti.approval_state,
(julianday(ti.updated_at) - julianday(ti.created_at)) * 24 AS hours_parked
FROM silas_tool_invocations ti
WHERE ti.approval_state IN ('approved','declined','expired');
The replay principle behind all of this: Silas::MessageBuilder reconstructs
the exact prompt at any step from these rows alone, deterministically. Nothing
in the trace reads the clock or mutable state — which is why a crashed turn
resumes byte-identically, and why the trace you query is the trace that
actually ran.