The read-only facade over Dendrux's six tables. Constructors, every read method with its exact signature, and the record types they return.
RunStore
RunStore is the public, read-only way to query everything Dendrux persisted about a run. It is a plain Python object you can call from any code (routes, admin scripts, background jobs) and it is what make_read_router is built on.
from dendrux.store import RunStore
store = RunStore.from_database_url("sqlite+aiosqlite:///app.db")
async with store:
run = await store.get_run(run_id)
events = await store.get_events(run_id)Constructors and lifecycle
@classmethod
def from_database_url(database_url: str) -> RunStore # store owns the engine
@classmethod
def from_engine(engine: AsyncEngine) -> RunStore # caller owns the engine
async def close() -> NoneBoth constructors also take blob_store= (a BlobStore) when the deployment keeps image bytes outside the database; use the same store as the Agent. Two members relate to images:
store.blobs # BlobAccessor: read, info, references, purge, collect_garbage
async def read_image(run_id: str, sha256: str, *, tenant_id=...) -> bytesread_image returns verified image bytes only if the run exists (and matches tenant_id when given) and references that blob; otherwise BlobNotFoundError, the same error for a missing blob, another tenant's blob and an unreferenced one. It is the call to put behind your own authorization when you serve images from a route. store.blobs.purge(...) and store.blobs.collect_garbage(...) are the only writes RunStore has. See Images.
from_database_url creates and owns an engine; close() (or exiting the async with) disposes it. from_engine reuses an engine you already have; close() is then a no-op and you dispose the engine yourself. RunStore is an async context manager.
Read methods
async def list_runs(
*,
status: str | list[str] | None = None,
agent_name: str | None = None,
parent_run_id: str | None = None,
tenant_id: str | None = None,
started_after: datetime | None = None,
started_before: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
limit: int = 50,
offset: int = 0,
) -> list[RunSummary]
async def count_runs(*, status=..., agent_name=..., parent_run_id=...,
tenant_id=..., started_after=..., started_before=...,
metadata_filter=...) -> int
async def get_run(run_id: str) -> RunDetail | None
async def get_events(run_id: str, *, after_sequence_index: int | None = None,
limit: int | None = None, event_types: list[str] | None = None,
before: datetime | None = None) -> list[StoredEvent]
async def stream_events(run_id: str, *, after_sequence_index: int | None = None,
poll_interval_s: float = 0.5) -> AsyncIterator[StoredEvent]
async def get_llm_calls(run_id: str, *, iteration=None, limit=None, offset=0,
before: datetime | None = None) -> list[LLMCall]
async def get_tool_invocations(run_id: str, *, iteration=None, limit=None, offset=0,
before: datetime | None = None) -> list[ToolInvocation]
async def get_traces(run_id: str, *, limit=None, offset=0) -> list[TraceEntry]
async def get_pauses(run_id: str) -> list[PausePair]
async def get_pii_mapping(run_id: str) -> dict[str, str]
async def get_subtree(run_id: str, *, tenant_id: str | None = ...) -> RunSubtree
async def get_notes(run_id: str, *, undelivered_only: bool = False,
tenant_id: str | None = ...) -> list[RunNote]get_subtree
A delegated run has child runs, which may have children of their own. get_subtree returns the whole tree under a run, with usage rolled up:
tree = await store.get_subtree(run_id)
for node in tree.nodes: # breadth-first, root first
print(" " * node.depth, node.run.run_id, node.run.status, node.usage.known_cost_usd)
print(tree.usage.input_tokens, tree.usage.output_tokens, tree.usage.cost_usd)RunSubtreehasroot_run_id,nodes(aSubtreeNodeeach:runas aRunSummary,depthbelow the root, and that run's ownusage),usage(the sum over every node) and arootproperty.RunUsagehasinput_tokens,output_tokens,cache_read_tokens,cache_write_tokens,reasoning_tokens,cost_usd,known_cost_usd,unpriced_callsandllm_calls.known_cost_usdis the sum over the priced calls;cost_usdequals it only when every call was priced and isNonewhen any was not (an unknown cost is never counted as zero).RunSubtree.usageis the live rollup of successfully recordedllm_interactions. It includes unfinished runs, but interaction writes are best-effort, so it is not a billing guarantee. Thetotal_*fields on each node'sRunSummaryare the snapshots persisted onagent_runsand may lag, or read 0 for a run still in progress. Useusagefor live spend; this is also the figure budgets enforce.- The reads are best-effort, not one snapshot transaction. A run that is still writing can make the node list and the usage differ by a call or a child.
- Tenancy. Omit
tenant_idfor a trusted, unscoped read of all descendants. Pass a string to scope to that tenant, or an explicitNoneto scope to untenanted runs. The root must be in scope (otherwise it reads as missing) and so must every descendant; a run outside the scope is excluded together with everything below it. - Raises
dendrux.store.RunNotFoundErrorif the root does not exist or is out of scope.
get_notes
notes = await store.get_notes(run_id, undelivered_only=True)The run's inbox notes as RunNote records (note_id, run_id, text, metadata, created_at, delivered_at, delivered_iteration) in delivery order. delivered_at is None for a note the model has not seen. With tenant_id= the run must belong to exactly that tenant or dendrux.store.RunNotFoundError is raised; without it, a missing run gives an empty list.
dendrux.store.RunNotFoundError(raised byRunStorereads) is a separate class fromdendrux.errors.RunNotFoundError(raised byAgentmethods). Catch the one that matches the API you called.
Time and type filters
get_events, get_llm_calls and get_tool_invocations accept before=, an exclusive upper bound on created_at. It must be timezone-aware; a naive datetime raises ValueError. get_events also accepts event_types=, a list of event type strings to keep (an empty list matches nothing):
from datetime import UTC, datetime
governance = await store.get_events(run_id, event_types=["policy.denied", "approval.requested"])
old = await store.get_llm_calls(run_id, before=datetime(2026, 10, 1, tzinfo=UTC))metadata_filter
list_runs and count_runs accept metadata_filter: dict[str, Any] (the HTTP router does not expose this). It does exact-match equality across every key in the run's meta JSON, AND across keys (jsonb @> on Postgres, repeated json_extract on SQLite). Keys are restricted to ^[A-Za-z0-9_-]+$; nested paths are out of scope. The canonical use is per-conversation observability:
runs = await store.list_runs(metadata_filter={"thread_id": "thread_abc"})
total = await store.count_runs(metadata_filter={"user_id": "u_123"}, status="error")See Chatbot threads for the full pattern.
Record types
All returned from dendrux.store. Fields, exact as persisted:
RunSummary (from list_runs): run_id, agent_name, status, created_at, updated_at, iteration_count, total_input_tokens, total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens, total_cost_usd, model, parent_run_id, delegation_level.
RunDetail (from get_run): every RunSummary field plus strategy, input_data, answer, error, failure_reason, cancel_requested (True between a cancel_run request and the run reaching a terminal status), pause_requested (True between a request_pause request and the run pausing).
StoredEvent (from get_events): sequence_index, iteration_index, event_type, correlation_id, data, created_at.
LLMCall (from get_llm_calls): iteration, provider, model, input_tokens, output_tokens, total_tokens, cache_read_input_tokens, cache_creation_input_tokens, cost_usd, duration_ms, provider_request, provider_response, created_at.
ToolInvocation (from get_tool_invocations): iteration, tool_name, tool_call_id, provider_tool_call_id, target, params, result, success, error, duration_ms, created_at.
TraceEntry (from get_traces): role, content, order_index, meta, created_at, phase ("commentary" for an assistant message that ended in tool calls, "final_answer" for the answer turn, None for other roles and rows written before phases were recorded).
RunSubtree, SubtreeNode, RunUsage (from get_subtree) and RunNote (from get_notes) are described under get_subtree and get_notes.
PausePair (from get_pauses): pause_sequence_index, pause_at, resume_sequence_index, resume_at, reason, pending_tool_calls, submitted_results, user_input.
Where this fits
- State persistence: the tables behind these records.
- make_read_router: the same reads over HTTP.
- HTTP API surface: the endpoint shapes.