Architecture reference
ax watches your coding-agent sessions on disk across six harnesses - Claude Code, Codex, Pi, oh-my-pi (omp), OpenCode and Cursor - turns them into a typed local graph, and lets you query the result without sending anything anywhere. This page is the architecture tour for the curious: the shape of the graph, the derived tables, and the readers built on top. For the product narrative, see how ax works.
The shape
Sessions land on disk in each harness's own home. Claude Code and Codex write .jsonl transcripts under ~/.claude/projects/ and ~/.codex/sessions/. Pi writes to ~/.pi/agent/sessions/, while Omp writes the same format under ~/.omp/agent/sessions/. OpenCode and Cursor keep SQLite stores. The ingest pipeline reads those sources into an embedded DuckDB cache. It keeps proposals, verdicts, experiments, and session labels in a separate SQLite judgment sidecar.
The core nodes are few. A session is one run from start to finish - it knows which project, which model, roughly when it started, and whether a commit came out the other side. A turn belongs to a session and carries the role (user, assistant, tool_result), a classified intent (organic task, correction, preference, wrapper instruction), and a short text excerpt for full-text recall. A tool_call belongs to a turn and records exactly what tool fired, what it was handed, what came back, how long it took, and whether it errored. A skill is a standing instruction installed on your machine - a markdown file that shapes how the agent behaves.
Connecting them are typed edge tables. A turn invokes a skill when the agent loads it. A session produced a commit. A commit touched a file. A turn can be corrected_by the next user turn. These edges keep relationships queryable without a vector index.
Derived tables - session_health, proposal, retro, friction_event, command_outcome, and semantic_signal - sit on top of the core nodes and summarise at session or cross-session scope. They are how the graph accumulates opinion over time, not just fact.
The stages
Each ingest stage has a reason for existing that lives next to its source as a @rationale comment. The section below is generated by walking the ingest pipeline and pulling those rationale headers out.
skills
Skills are the agent's standing instructions. Indexing them up-front means later stages can ask "which skills exist" without re-walking the filesystem on every query, and the dashboard can show a static catalogue without reading transcripts at all.
Inputs: ~/.claude/skills/, ~/.agents/skills/, plugin caches
Outputs: skill rows, plays_role edges
Source: apps/axctl/src/ingest/skills.ts
agent-def
Subagent definition files (~/.claude/agents/*.md + per-repo .claude/agents/*.md) are config the agent declares but the graph was previously blind to (only scope-read, no table). Indexing them as a first-class reconciled entity - same lifecycle as skills - lets the dashboard list agents, their declared skills, and their model, and lets reconcile tombstone agents deleted off disk instead of ghosting forever.
Inputs: ~/.claude/agents/.md, <repo>/.claude/agents/.md
Outputs: agent_def rows (soft-tombstoned on disappearance)
Source: apps/axctl/src/ingest/agent-def.ts
invoked-positions
Computes and writes turn_index, total_turns, and is_first onto every invoked edge that still carries NONE for any of those fields. turn_index is written at RELATE time for new ingests, but total_turns and is_first require the full turn count per session and the per- (session, skill) group ordering - information that is only stable after all transcripts are ingested. This stage runs after claude, codex, and subagents to fill in those values.
Inputs: invoked edges with NONE position fields
Outputs: invoked.turn_index, invoked.total_turns, invoked.is_first
Source: apps/axctl/src/ingest/backfill-invoked-positions.ts
cache-bust
Populate the cache-bust ledger (#868): one priced row per usage row whose billing event carried a cache_miss_reason, the substrate for ax cost cache (what re-injects your context, and what re-establishing the cache costs). The whole derivation is the cache-bust SQL MODEL (models/cache-bust-event.sql) - filter + ingest-priced cache-bust cost, executed inside DuckDB; no rows cross the JS boundary. Query-time root corroboration uses independent OTLP cost, so it never repeats root cost per bust. Incremental by the ingest since-window; id == turn_token_usage.id so re-runs UPSERT idempotently; version-marked cutover wipes + fully re-derives when the model SQL changes.
Source: apps/axctl/src/ingest/derive-cache-bust.ts
derive-content-types
Build a has_content edge from each tool_call to the closed content_type taxonomy node that best describes its output. Extension matching on the file_path from the tool input is the strongest signal; a lightweight content sniff handles Bash/exec output that has no path; a text fallback closes the set. Category nodes are a fixed closed taxonomy (12 values) upserted once per ingest run. The edge is keyed by tool_call id so re-runs are idempotent.
Inputs: tool_call rows: id, session, name, input_json, output_excerpt, bytes, ts
Outputs: content_type nodes (upsert, idempotent) + has_content edges
Source: apps/axctl/src/ingest/derive-content-types.ts
derive-run-evidence
Populate the run-evidence ledger (#578) by normalizing structural rows already in the graph into run_evidence_event rows. This first slice covers the four UNAMBIGUOUS, provider-agnostic sources whose backing is structurally determined (no NLP, no trust guesses):
Inputs: session (id, source), tool_call, command_outcome, compaction, plan_snapshot rows (deref-free projections).
Outputs: run_evidence_event rows (idempotent UPSERT, keyed by session+source_table+source_id) + run_evidence_ref file refs off each tool_observation event, from read_file/searched_file edges (path HASHED, privacy ref_only). edited (turn->file) is deferred - it has no tool_observation event to anchor to.
Source: apps/axctl/src/ingest/derive-run-evidence.ts
loaded-skills
The invoked edge captures only explicit Skill-tool calls (and, via commands.ts, slash-commands). Skills that load because a subagent's skills: frontmatter pulls them in NEVER produce a Skill-tool call, so they leave no invoked edge - they are invisible to every usage view even though they were activated. This stage draws that missing activation signal as a SEPARATE loaded edge (session -> skill) so it can light up edit→outcome analysis (see docs/.../churn-as-gate-grade-experiment.md) WITHOUT polluting invoked-based usage analytics (skills weighted, taste, churn).
Inputs: spawned edges (which subagent spawned, when), agent_def.skills (the agent's declared skill list), skill rows (name -> id resolution)
Outputs: loaded edges: child session -> skill, SET ts, agent, source
Source: apps/axctl/src/ingest/derive-loaded-skills.ts
As each remaining ingest stage gets its @rationale comment, it appears here automatically without further edits to this page.
The readers
Once the graph is built, a small set of typed queries drive everything the CLI and dashboard expose. They are not general-purpose queries; each one is shaped to a specific product question.
ax improve list reads proposal rows ordered by score, joining back through cites_evidence to the friction, command outcome, or session evidence that generated them. It answers: what should I try changing about how I work?
ax retro pending reads session rows that ended without a corresponding retro record. It answers: which recent sessions haven't been reflected on yet?
ax recall <term> runs a BM25 full-text query across turn text, commit messages, and skill descriptions, then ranks by combined term presence. It answers: where did I deal with this before?
The dashboard insight views read session_health aggregates - turn counts, tool error rates, correction counts, context pressure - grouped by workflow_epoch so you can see how a week of work compares to the one before it.
Reader rationale annotation - the same @rationale pattern applied to query modules - follows the same approach as the ingest stages: annotate the source, run the extractor, the docs update themselves.
Why this shape
Local-first. Every piece of evidence ax collects stays in local files by default. Query commands read a published DuckDB snapshot. Ingest writes the live cache under a lock, then publishes a new snapshot. The SQLite sidecar preserves judgments when you rebuild the cache. No database server is required.
Graph, not vector index. The useful questions are relational. Ax can ask which tool calls preceded a correction or which sessions used a skill and produced commits. DuckDB tables make each relationship a queryable fact. The schema comment puts it plainly: skill ← invoked ← turn → edited → file.
Two stores, one boundary. Rebuildable transcript evidence belongs in DuckDB. Durable user decisions belong in SQLite. Reads use the published cache snapshot. Writes use the live cache during ingest. Cross-store joins happen in application code.
ax studio serves the published snapshot only while a browser client is attached. It stops after the client leaves. The optional ax otlpd telemetry receiver is the only long-running service that installation can add.