The six tables dendrux writes to, what each one stores, and why the schema is shaped the way it is.
State persistence
Everything dendrux knows about a run lives in your database. Not in memory, not in a queue, not in a sidecar process. Six SQLAlchemy tables, one ULID per row. When a run pauses, it persists. When a different process resumes it, it reads those rows.
This page walks through each table with real row contents from a vetted run (dendrux==0.2.0a1 against SQLite). Every value below was copied from a real quickstart.db produced by the Quickstart example.
Building a product on top of this? These six tables are Dendrux's run-state and audit surface, not your product database. If you are building a chat app, store your user-visible chats and messages in your own tables and link to Dendrux by run ID. See Your app DB vs the Dendrux DB for the boundary, a minimal schema, and the request lifecycle.
Why on-disk state
In-memory agent state evaporates the moment a process crashes, gets killed, restarts, or falls behind a load balancer that routes the next request to a different worker. Dendrux's whole point is that an agent can pause for minutes or hours (waiting on a human, a slow tool, or an upstream service) and resume from anywhere. That requires the state to live somewhere durable that any process can read.
A library, not a service. Dendrux doesn't run a server, doesn't manage workers, doesn't sweep for crashed runs. It just gives every run a row in your existing database. If your application can talk to a database, it can resume a run.
The six tables
The agent_runs table is the anchor. Everything else cascades from it: delete a run row and all its traces, tool calls, and events go with it.
New in 0.4.0: two image tables,
dendrux_blobs(content-addressed image bytes and metadata, keyed by tenant scope and SHA-256) anddendrux_blob_refs(which run uses which blob). They hold images only; a deployment that never uses images has them empty. See Images. Postgres needsdendrux db migrate(revisionc6d7e8f9a0b1) even if you never use images; see Upgrading.
New in 0.3.0: a seventh table,
run_inbox, holds notes submitted to a running agent withsubmit_note. It cascades with the run like the others. Seerun_inboxbelow. The "six tables" wording elsewhere in these docs counts the original set.
All IDs are 26-character ULIDs (time-sortable, see Runs and the lifecycle).
agent_runs: the anchor
One row per agent.run() call. Holds the run's identity, status, configuration, and roll-up totals. Real row from the quickstart example:
id: 01KPFXP9M96CXNR9JYT8KJ27J0
tenant_id: None
agent_name: Agent
status: success
input_data: {"input": "Please refund order 42."}
output_data: {"answer": "I've successfully issued a refund..."}
iteration_count: 2
model: claude-haiku-4-5
strategy: NativeToolCalling
parent_run_id: None
delegation_level: 0
retry_of_run_id: None
total_input_tokens: 1262
total_output_tokens: 82
total_cost_usd: None
total_cache_read_tokens: 0
total_cache_creation_tokens: 0
meta: {"dendrux.loop": "ReActLoop", "dendrux.max_delegation_depth": 10}
pause_data: null
pii_mapping: None
error: None
last_progress_at: 2026-04-18 09:13:35.342991
failure_reason: None
idempotency_key: None
idempotency_fingerprint: None
cancel_requested: 0
pause_requested: 0
created_at: 2026-04-18 09:10:32
updated_at: 2026-04-18 09:13:35A few columns deserve a closer look:
statusis the only mutable field that changes during a run's life. Everything else is either set once at creation (agent_name,input_data,model) or filled in at terminal time (output_data,error).pause_datais a JSON blob holding everything needed to resume: pending tool calls, conversation history, step list, current iteration. It'snullhere because the run completed; it's populated when the run is in awaiting_*status. Cleared on terminal finalize.pii_mappingis also JSON, but unlikepause_datait is not cleared on finalize. It's part of the audit trail. See PII redaction.cancel_requestedis the cooperative cancellation flag. The runner reads it at safe checkpoints. See Cancellation.pause_requestedis its twin forrequest_pause(added in 0.3.0, default false). It is cleared when the run pauses, is claimed for resume, or finishes. See Pause and resume.statusalso takes the valuepausedfor a requested or budget pause. It is a plain string column, so no enum migration was needed.last_progress_atis the liveness timestamp the stale sweep reads; see below.metais opaque JSON for your application. Dendrux stores it and ships it back; it never reads it. Use it to link a run to your user IDs, request IDs, ticket numbers, anything.parent_run_id+delegation_levelform the sub-agent tree. When an agent spawns a sub-agent, the child gets its ownagent_runsrow withparent_run_idpointing back.
Three indexes exist on this table: parent_run_id (for the delegation tree), status (for "find all running runs"), and created_at (for "most recent first").
react_traces: the conversation
The chronological message history shipped to the LLM on every iteration. One row per message.
For the quickstart's two-iteration run, this table has 4 rows: the user's question, the assistant's tool call, the tool result, and the assistant's final answer. Here's the first row:
id: 01KPFXP9MYZNYTV09FHEBYFKXF
agent_run_id: 01KPFXP9M96CXNR9JYT8KJ27J0
role: user
content: Please refund order 42.
order_index: 0
meta: {"iteration": 0}
created_at: 2026-04-18 09:10:32The order_index is what guarantees stable ordering. SQL timestamps aren't precise enough (two messages in the same millisecond would tie); order_index is monotonic per run.
role is one of user, assistant, tool. There's a composite index on (agent_run_id, order_index) so loading a run's history is one indexed range scan.
Sub-agent isolation is structural: a sub-agent has its own agent_run_id, so its traces are naturally a separate set. No instance_id or delegation columns are needed inside this table.
tool_calls: what tools ran
One row per tool invocation. Captures the call identity, parameters, result, success/failure, and timing.
id: 01KPFXVTPB7M86Z2GZ5G52V4D8
agent_run_id: 01KPFXP9M96CXNR9JYT8KJ27J0
tool_call_id: 01KPFXPAT8ZF7MNGJNW2Y6S8GZ
provider_tool_call_id: toolu_01AKnswC3GgaRwf1gkZSNdbh
tool_name: refund
target: server
params: {"order_id": 42}
result: "Refunded order 42"
success: 1
duration_ms: 0
iteration_index: 1
error_message: None
created_at: 2026-04-18 09:13:34Two ID columns are not a duplicate. tool_call_id is dendrux's stable ULID, used everywhere internally. provider_tool_call_id is whatever Anthropic or OpenAI assigned (toolu_... here). Storing both means dendrux can round-trip tool results back to the provider on the next iteration without ever depending on the provider's ID format being parseable or stable.
target is one of server (executed in your process) or client (shipped to the browser). When target=client, the runner pauses and writes a row only after the client submits the result via submit_tool_results.
llm_interactions: the authoritative LLM record
One row per provider.complete() call. This is the table that exists for the evidence layer: it stores both dendrux's normalized view and the exact vendor-specific wire format.
id: 01KPFXPAT8ZF7MNGJNW2Y6S8H0
agent_run_id: 01KPFXP9M96CXNR9JYT8KJ27J0
iteration_index: 1
model: claude-haiku-4-5
provider: AnthropicProvider
semantic_request: {"messages": [...], "tools": [...]} ← dendrux-normalized
semantic_response: {"tool_calls": [...], "usage": {...}} ← dendrux-normalized
provider_request: {"model": "claude-haiku-4-5", "max_tokens": 16000, ...} ← raw Anthropic kwargs
provider_response: {"id": "msg_01XAzX8uRdmy8qiL242Q8kK8", ...} ← raw Anthropic dump
input_tokens: 594
output_tokens: 55
duration_ms: 1190
cache_read_input_tokens: 0
cache_creation_input_tokens: 0
guardrail_findings: null
created_at: 2026-04-18 09:10:34The semantic_* columns let you ask "what did dendrux think it sent?" The provider_* columns let you ask "what actually went over the wire?" Both matter for compliance, debugging, and replay. If a provider does something unexpected, the raw response is preserved verbatim.
guardrail_findings is best-effort enrichment from the Guardrails engine. It's not the authoritative audit record (governance events in run_events are), just a convenience attached to this row.
token_usage: legacy, kept for backwards compat
Same shape as the token columns on llm_interactions, one row per LLM call. Older versions of dendrux only had this table; llm_interactions superseded it but token_usage is still written so existing dashboards keep working.
id: 01KPFXPATDNPK7KX68QYKVMXDX
agent_run_id: 01KPFXP9M96CXNR9JYT8KJ27J0
iteration_index: 1
model: claude-haiku-4-5
provider: AnthropicProvider
input_tokens: 594
output_tokens: 55
duration_ms: None
created_at: 2026-04-18 09:10:34If you're starting fresh, query llm_interactions instead.
run_events: the ordered audit log
Append-only event log for everything observable about a run: lifecycle transitions, LLM completions, tool completions, governance decisions. The dashboard and SSE stream are both built on this table.
id: 01KPFXP9MXHCM94YH8EAV79YKK
agent_run_id: 01KPFXP9M96CXNR9JYT8KJ27J0
event_type: run.started
sequence_index: 0
iteration_index: 0
correlation_id: None
data: {"agent_name": "Agent",
"system_prompt": "You are a support agent..."}
created_at: 2026-04-18 09:10:32The two-iteration quickstart run produced 9 events:
The sequence_index column is the canonical ordering key inside a run. Two events created in the same millisecond would tie on created_at, but sequence_index is strictly monotonic per run. The composite index (agent_run_id, sequence_index) makes "give me everything after sequence N" a single indexed range scan, which is what powers SSE resume. See Event ordering for the full mechanism.
correlation_id links related events together. A tool.completed event carries the tool_call_id it corresponds to. A run.resumed event carries the correlation_id of the original run.paused it picks up from. This avoids ambiguity in multi-tool, multi-pause runs.
data is JSON: the event-specific payload. Privacy: only observable data goes here. The unredacted execution state lives in agent_runs.pause_data, which the dashboard never queries.
run_inbox: notes for a running agent
One row per submit_note call. Columns: id (ULID), run_id (foreign key to agent_runs, ON DELETE CASCADE), text, metadata (JSON, yours, never shown to the model), created_at, delivered_at and delivered_iteration. delivered_at is NULL until the runner delivers the note to the model, which is how an undelivered note is told apart from a delivered one. The index (run_id, delivered_at, created_at, id) serves the runner's "what is still pending, in order" query. Read it through RunStore.get_notes; see Inbox notes.
Unlike the append-only tables, run_inbox rows are updated once: delivered_at and delivered_iteration are set at delivery.
Append-only is a feature
The four child tables (react_traces, tool_calls, llm_interactions, run_events) are insert-only. (run_inbox is the one exception, above.) Once a row is written, dendrux never updates it. Only agent_runs.status and the roll-up totals change over time.
This is on purpose. An audit trail you can rewrite isn't an audit trail. If something looks wrong in the dashboard, the raw rows are the source of truth. Migrations preserve old rows; bug fixes add new columns rather than rewriting existing data.
Indexes
The schema ships with four indexes that matter for typical query patterns:
You can add your own indexes if your queries need them. These are the structural ones dendrux relies on.
Long-running tools and the stale sweep
sweep() is the maintenance call you schedule yourself (at startup, from a cron). With stale_running=timedelta(...) it finds running rows whose last_progress_at is older than the threshold, assumes the process died, and marks them error (failure_reason stale_running, or never_started if no run.started event exists) with a run.interrupted event. A stale row with a cancel pending is finalized cancelled instead. With abandoned_waiting=timedelta(...) it reaps waiting_* and paused rows nobody resumed.
last_progress_at moves forward on every LLM call, every tool start and every tool completion. A tool that blocks longer than the sweep threshold with no other progress (waiting on a human, a long export) looks dead. Call the heartbeat from inside the tool to say it is alive:
from dendrux import aheartbeat, heartbeat, tool
@tool()
async def export_report(report_id: int) -> str:
"""Build a large report."""
for chunk in range(100):
await build_chunk(report_id, chunk)
await aheartbeat() # awaits the write
return "done"
@tool()
def slow_sync_tool() -> str:
"""Sync tools run in a worker thread."""
for _ in range(100):
do_blocking_work()
heartbeat() # fire-and-forget, thread-safe
return "done"heartbeat() is safe from async code and from threads, returns immediately and coalesces calls less than a second apart. aheartbeat() must be awaited on the run's event loop and never raises. Both do nothing outside a persisted run.
Choose the sweep threshold above the longest gap between progress updates. If
stale_runningis shorter than the longest stretch a live run can go without an LLM call, a tool start or end, or a heartbeat, the sweep will finalize a run that is still executing. Known limitation: the still-running process keeps writing events with the sequence indexes it already allocated, so those indexes can collide with the sweep'srun.interruptedevent. Size the threshold for your slowest tool, and heartbeat from long ones, so the sweep never touches a live run.
Database choice
Dendrux uses SQLAlchemy 2.x async. Anything async-SQLAlchemy supports works:
- SQLite + aiosqlite: bundled, zero-setup, perfect for local dev and small deployments.
- PostgreSQL + asyncpg: recommended for production.
pip install "dendrux[postgres]". Schema is identical; migrations are managed by Alembic. - Other backends (MySQL, etc.) work in principle but aren't routinely tested.
The schema doesn't use any SQLite-specific or Postgres-specific features. Switching backends is a connection string change.
In-memory SQLite (sqlite+aiosqlite:///:memory:) is for tests and demos only. SQLAlchemy gives it one shared connection, so Dendrux serialises its own database sessions on it (parallel tool calls and readers wait their turn) and logs a warning when a store is created on it. The data is gone when the engine is disposed. Two rules apply:
- Use one event loop per in-memory engine.
- Never hold a Dendrux session (
dendrux.db.session.get_session) open across a call that runs an agent, for example for a whole web request that callsagent.run(). The agent's database writes wait for that session, so the call never finishes. A warning names the holding task after 10 seconds.
A SQLite file or Postgres has none of these limits.
Setting up the schema
SQLite creates its tables automatically on first use, so there is nothing to run for a new file. A SQLite file created by an older release is upgraded in place on startup. The upgrade is add-only: it adds missing nullable or defaulted columns and indexes (0.3.0 adds agent_runs.pause_requested and the run_inbox table). If a column cannot be added safely, Dendrux raises an error naming the table and column and telling you to run dendrux db migrate or recreate the file. Every other backend needs the tables created once before the first run. Dendrux ships an Alembic migration set and a small CLI for exactly this. Upgrading an existing Postgres database to 0.3.0 is the same command; it applies revision b5c6d7e8f9a0 (the pause_requested column and the run_inbox table):
pip install "dendrux[postgres]" # asyncpg driver (alembic is already bundled)
export DENDRUX_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname
dendrux db migrate # create / upgrade the schema
dendrux db status # show the current migration revisionDENDRUX_DATABASE_URL is the one knob the CLI reads. It never accepts the URL as a flag, so credentials stay out of your shell history and process list. The same variable is also the fallback your app uses when you build an Agent without an explicit database_url=, so setting it once covers both migration and runtime.
If the tables are missing when a run starts, dendrux raises SchemaNotInitializedError carrying these exact commands, rather than letting a raw "relation does not exist" error surface mid-run.
Upgrading
Upgrading to 0.4.0 adds the two image tables; a Postgres database that has not been migrated raises SchemaOutOfDateError naming dendrux_blobs and dendrux_blob_refs on first use. SQLite files create them on startup.
After you upgrade the dendrux package, run dendrux db migrate against your database again, then restart your app. If you forget, dendrux does not let the first run fail with a raw UndefinedColumnError. On first use of a non-SQLite database (an Agent run, sweep, or the dashboard) it verifies that every table and column this version needs is present, and raises SchemaOutOfDateError naming what is missing, for example agent_runs.pause_requested and table run_inbox when you move from 0.2.0a23 to 0.3.0.
The check is read-only (it only inspects table and column names, matched exactly), runs once per engine, and does not run on SQLite, which upgrades itself. It checks presence only: it does not compare column types, nullability or defaults. It does not read Alembic's version table, so it works if you create the schema some other way. Extra tables, extra columns and missing indexes are not errors. RunStore is read-only apart from blobs.purge and blobs.collect_garbage, and is not checked, so a reporting database that exposes the tables as views keeps working. If the database refuses the catalog read (for example for lack of privileges), dendrux logs a warning and carries on; connection failures still raise. If you manage the schema yourself and want to turn the check off, set DENDRUX_SKIP_SCHEMA_CHECK=1.
Reading runs back through RunStore
You do not query these tables with raw SQL. RunStore is the public read API over the same six tables: one method per concern, keyed by run_id. Here a run is executed, then read back from a fresh store handle (which could be a different process):
import asyncio
from dendrux import Agent
from dendrux.llm.anthropic import AnthropicProvider
from dendrux.store import RunStore
async def main() -> None:
url = "sqlite+aiosqlite:///quickstart.db"
async with Agent(
name="QA",
provider=AnthropicProvider(model="claude-haiku-4-5"),
prompt="Answer in one short sentence.",
database_url=url,
) as agent:
result = await agent.run("What is the capital of France?")
# Read the persisted state back through the public API.
store = RunStore.from_database_url(url)
async with store:
run = await store.get_run(result.run_id) # the agent_runs row
events = await store.get_events(result.run_id) # the run_events timeline
llm_calls = await store.get_llm_calls(result.run_id) # one row per LLM turn
print(run.status, run.model)
# success claude-haiku-4-5
print([e.event_type for e in events])
# ['run.started', 'llm.completed', 'run.completed']
print(llm_calls[0].model, llm_calls[0].input_tokens, llm_calls[0].output_tokens)
# claude-haiku-4-5 20 10 (token counts vary per run)
asyncio.run(main())RunStore also exposes get_tool_invocations, get_traces, get_pauses, get_notes, get_subtree and list_runs(metadata_filter=...). See Chatbot threads for the metadata-filter query patterns and Mount the read router to expose these reads over HTTP.
What's next
- Event ordering: why
sequence_indexexists and how SSE resume uses it. - Pause and resume: what
pause_dataactually contains and howsubmit_*unfreezes a run. - Cancellation: the
cancel_requestedflag and the atomic CAS that finalizes a run safely.