This page walks the ingest loop end-to-end — from a Claude Code hook firing on the Mac
Mini to a Sankey diagram redrawing in a browser tab, plus the side branch that relays an
alert to Telegram — and explains the ports-&-adapters seams that keep each stage
independently testable. The key takeaway: agenthropic is a single-writer pipeline
(events_raw → deterministic projection → read-only fan-out), built so that two
invariants hold no matter which stage is under load or mid-crash — token counts are
ground truth read from ~/.claude/projects/*.jsonl, never inferred, and agents and
subagents are first-class, queryable, persisted rows, not a tree the UI reconstructs
from an event log on the fly. Everything else in this document — the named ports, the
schema shape, the transport choice — exists to protect those two guarantees under
restart, partial failure, and an unverified hook catalog.
Update — 2026-07 (as built). Implementation began 2026-07-11 (owner override of the CD-8 hard stop; the security invariants, the KC calendar, and the PROVISIONAL status of the spike numbers are unchanged). The running system differs from the original design sketch below in one deliberate way: JSONL transcripts are parsed and projected directly —
parseSession(pure, inpackages/core) feedssessions/agents/orchestration_edges/token_usageinside one transaction per session (apps/server/src/ingest/ingest-session.ts), with cost computed before any write so an unpriceable model halts the session’s ingest rather than storing a silent $0.events_rawtherefore holds hook events only, and the separate Normalizer → Projection stages sketched below were never built as distinct pipeline stages. CD-1 is intact — hooks contribute liveness only, never structure — and replay stays idempotent. Hook deliveries are additionally normalized into a smalleventsliveness timeline (identifiers only, same transaction, never the payload body). The Telegram/webhook alert branch remains post-1.0 and unbuilt. Sections below that describe the two-stage substrate design are the design history; per-section updates mark what actually runs.
The canonical shape of the pipeline (the design basis, §3 — see the as-built update above for where the running system deliberately simplifies this):
Claude Code (subagents)
│ hooks (lifecycle events)
▼
hook-ingest ──► SQLite (persisted, WAL) ──► SSE ──► browser SPA
▲ │ (DAG + Sankey)
│ └──► webhook sink ──► Telegram relay (@baev_bot_bot)
└── reads ~/.claude/projects/*.jsonl (ground-truth token counts)
Two things to notice immediately:
hook-ingest has two inbound edges, not one. It receives live hook events and
reads the JSONL transcript directly — the transcript is not a side archive, it is a
primary input the ingest loop reconciles against. Which of the two is authoritative
for which fact is exactly the question the ingest-reconciliation page answers in depth
(see ingest & reconciliation); this page
only establishes that both feed the same pipeline.hook-ingest already committed to SQLite — it never dials a URL taken
from a hook payload (that is the SSRF pattern this system structurally avoids; see
security model).The reference implementation the design basis names as the clearest teaching example of
this exact shape is disler’s ~180-line send_event.py: hook → HTTP → SQLite → WS
(DESIGN §3, §7). agenthropic learns the loop from it but does not build on it — no
license, no tests, and its server drops agent_id/agent_type on the floor, which is
precisely the fact this system exists to keep.
Reading the diagram left to right, one subagent turn produces (at minimum) this sequence:
PreToolUse, PostToolUse,
UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart/SessionEnd,
PreCompact, PermissionRequest, PostToolUseFailure, and — unverified, see
What’s undecided below — SubagentStart) and POSTs it to the
loopback hook-ingest endpoint (DESIGN §5).hook-ingest accepts and stores the raw event regardless of type — an
unrecognized or new hook must never crash the pipeline; the normalizer keys only off
verified event types and a schema_version (concept-analysis-v2 §4.2, Developer lens).
This raw write lands in an immutable, append-only substrate (events_raw) before
anything is interpreted.hook-ingest reads ~/.claude/projects/*.jsonl for the session —
this is the only source ever consulted for token counts, and — per the 2026-07-04
desktop probe, which pre-answers CD-1 CONDITIONAL-GO (confidence 85) — a proven
primary source for the subagent parent→child linkage itself: the depth-1 parent→child
edge is a hard key with a 0% orphan rate (concept-analysis-v2 §2, LB1/CD-1;
phase-0 probe).events_raw into queryable state: rows in
sessions, agents (self-referential tree), orchestration_edges, and token_usage.
This projection is a pure function of the immutable log — replaying the same log twice
must yield byte-identical state (concept-analysis-v2 CD-2).alert_rules) and relays formatted payloads through the Telegram bot integration
(@baev_bot_bot) — not yet built. DESIGN §9’s original sketch places this at Phase 2;
the canonical, dependency-checked roadmap resequences it to
Phase 5 once its real dependencies are accounted for — DESIGN §9’s “Phase 2” is
superseded, not current — and per the best-path decision
(best-path-decision.md §6.1) the alerts
track ships post-1.0 (data model and
cost model record the same resolution).Update — 2026-07 (as built). Steps 2–4 ran into the empirical facts: Claude Code ships four of the listed hooks in a wireable form (
UserPromptSubmit,Stop,SubagentStop,PreCompact—SubagentStartdoes not exist; spike S4), and the as-built ingest does not route JSONL throughevents_rawat all. A polling corpus watcher detects change by comparing a per-session fingerprint — the main transcript’ssize:mtimeplus a sortedrel:size:mtimeline for every sidecar artifact, all read throughlstat— then the pure parser reconstructs the session, cost is computed as a halt gate, and one transaction writes the projections. There is nofs.watchanywhere; polling is the deliberate choice, because a missed filesystem notification is a silent data loss while a missed poll is only latency. The interval defaults to 3 s (DASHBOARD_POLL_INTERVAL_MS) — like every other tuning constant quoted on this page, that number is PROVISIONAL (LABEL-ME): it was chosen to feel live on one machine, not derived from a measured load target. Hook POSTs land inevents_raw(append-only, idempotency-keyed, redacted first) plus oneeventsliveness row. Step 7 (webhook sink / Telegram) is unbuilt, post-1.0.
| Component | Responsibility | Reads | Writes | Source |
|---|---|---|---|---|
| Claude Code hooks | Emit lifecycle events for the twelve (unverified count, see below) hook types | — | HTTP POST to hook-ingest |
DESIGN §5 |
~/.claude/projects/*.jsonl |
Durable per-session transcript; the only ground-truth source for token counts, and proven primary source for subagent linkage (CD-1 pre-answered by the desktop probe) | Claude Code’s own session writer | — | DESIGN §3, §7; concept-analysis-v2 LB1 |
hook-ingest |
Accept-and-store any event type without crashing; read the JSONL transcript; reconcile hook liveness against JSONL truth | Hook POSTs, JSONL | events_raw |
DESIGN §3; concept-analysis-v2 §4.2 |
| Normalizer / Projection | Pure, replayable function: events_raw → sessions/agents/orchestration_edges/token_usage |
events_raw |
Projected tables | concept-analysis-v2 CD-2/CD-3 |
| SQLite (WAL) | Single persisted substrate; source of truth for every downstream reader | Projection output | — | DESIGN §3, §8 |
| Realtime transport | Server→browser fan-out of state deltas, same-origin enforced | Projected tables | Push to SPA | DESIGN §3; concept-analysis-v2 CD-5 |
| Browser SPA | Render the subagent DAG and the token/cost Sankey view; read-only | Realtime feed + read API | — | DESIGN §3, §6 |
| Webhook sink | Match alert_rules against persisted state; format and deliver to configured targets only |
Projected tables, alert_rules |
webhook_deliveries |
DESIGN §4 (hoangsonww graft); Phase 5, post-1.0, per the roadmap (DESIGN §9’s sketch says Phase 2 — superseded) |
| Telegram relay | Deliver formatted alerts to @baev_bot_bot |
Webhook sink payload | Telegram API | DESIGN §2.3, §7 (formatTelegram) |
Update — 2026-07 (as built). In the running system the “Normalizer / Projection” row is realized as the pure parser (
packages/core/src/parser) plus the single-transaction session writer — not a separateevents_raw-fed stage; thehook-ingestrow isPOST /api/hooks/event(auth-gated, accept-any-shape, 202); the hook catalog is four real events, not twelve; and the webhook-sink / Telegram rows are unbuilt (post-1.0).
The design basis names this pattern explicitly as the structural backbone (DESIGN §3,
§7: “ports/adapters storage + strategy-pattern agent classes” from simple10).
concept-analysis-v2’s architect lens (CD-6) names the concrete seam set the core is built
around:
| Port | Purpose | Adapter today |
|---|---|---|
HookSource |
Accept Claude Code lifecycle events | Per-runtime strategy class (Claude Code now; the same seam is what would let a Codex adapter be added later without a core rewrite) |
TokenReader / TokenSource |
Read ground-truth token counts | ~/.claude/projects/*.jsonl reader |
EventStore |
Append-only write/read of events_raw |
SQLite table with no UPDATE/DELETE path |
| Normalizer / Projection | Pure function: raw events → normalized state | In-process projection, replayable from events_raw alone |
StoragePort |
Persisted read/write of projected tables | better-sqlite3, WAL mode — single driver (the node:sqlite fallback was dropped per best-path §6.3, applied 2026-07-06) |
RealtimeHub |
Push projected-state deltas to connected clients | SSE endpoint, same-origin enforced |
AlertSink |
Match rules against state, deliver outbound | Webhook target → Telegram formatter |
PricingProvider |
Versioned model_pricing lookup |
SQLite table, keyed by (model, bucket, effective_from) — a dated lookup that picks the newest row not later than the message (the verified_on column sketched under CD-4 was never built; provenance of a rate lives in the migration that seeded it, not in a column) |
CostEngine |
Combine token_usage + PricingProvider into dollar cost and delegation-savings |
Pure computation over persisted data |
(Port names and the full rationale: concept-analysis-v2 §3, CD-6. This table is a summary for the architecture-overview reader — treat concept-analysis-v2 as the source of record if the two ever drift.)
Update — 2026-07 (as built). The seams that exist in code today: a read-only
CorpusFsport (the JSONL reader —TokenReader’s realization), the append-onlyEventStorePort(hook envelopes →events_raw+ theeventsliveness projection), theRealtimeHub(SSE fan-out), and the read-onlySubstrateProviderseam for the cost-analysis endpoint. The pure parser + cost engine live inpackages/corewith no DB imports.AlertSinkhas no adapter yet (alerts are post-1.0), and there is no separate Normalizer/Projection pair — see the page-top update.
Why this shape, concretely:
events_raw — it
can be exercised entirely from recorded fixtures, with no live Claude Code session,
no network, and no clock dependency. This is what makes replay-on-startup and the
“double-replay → byte-identical state” test (concept-analysis-v2 §6) possible at all.simple10’s
hooks/scripts/lib/agents/<class>.mjs cleanly separates the Claude-Code-specific
ingestion shape from everything downstream (DESIGN §3, §7) — HookSource is the port
that pattern maps onto.hoangsonww’s dual SQLite driver
(better-sqlite3 with a node:sqlite fallback) illustrates why the seam matters —
a driver swap stays local to the adapter because StoragePort is a seam, not a
direct dependency scattered through the codebase (DESIGN §7). agenthropic itself
ships a single better-sqlite3 driver — the dual-driver graft was dropped per
best-path §6.3 (applied 2026-07-06); the seam argument stands on its own.Every token count the system ever displays or prices is read from
~/.claude/projects/*.jsonl, never estimated from tool-call counts, never
model-guessed, never backfilled by heuristic when a cheaper number is available. This is
stated as a design invariant (DESIGN §3) and reaffirmed by the architect lens as
“architecturally honest — tokens are read, never inferred” (concept-analysis-v2 §5,
Strengths). The reconciliation precedence that enforces this in the schema:
token_usage.agent_id may be NULL at first write (a token row can arrive before
the owning agent is known) but is deterministically backfilled once the agent
resolves — never left as a guess, never double-counted (concept-analysis-v2 CD-3, §6).token_usage (bucketed by speed /
inference_geo / service_tier) so a session that hits PreCompact still reprices
correctly against its pre-compaction figures rather than silently losing history
(DESIGN §4; concept-analysis-v2 §6, Cost). (As built, the buckets that actually
materialized are the API’s real price axes — input / output / cache_read /
cache_write_5m / cache_write_1h, one row per (message_id, bucket); the
speed/inference_geo/service_tier axes from the design sketch did not survive
contact with the real JSONL. The table does carry an is_compaction_baseline column,
but it is dead — the writer inserts a literal 0 and nothing ever reads it back
(implementation review 2026-08-09, finding L-7). Compaction repricing works by walking
the transcript’s own compaction boundaries at analysis time, not by consulting a
persisted flag; the column is recorded here so nobody plans against a marker the code
does not maintain. See data model and
cost model.)This is why the ingest loop reads the JSONL transcript directly rather than trusting hook payloads for anything cost-bearing — see cost model for the full pricing/bucket design.
The subagent tree is a data fact, not a client-side reconstruction from a flat event
log (DESIGN §3). Concretely, agents is self-referential:
CREATE TABLE agents (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
type TEXT CHECK(type IN ('main','subagent')),
subagent_type TEXT,
status TEXT CHECK(status IN ('working','waiting','completed','error')),
parent_agent_id TEXT, -- self-ref: builds the subagent tree
FOREIGN KEY (parent_agent_id) REFERENCES agents(id) ON DELETE SET NULL
);
(DESIGN §4 — hoangsonww graft. As built, the real agents table keeps this shape but
its status CHECK carries five values — 'working','waiting','completed','error','unknown'
— because unknown is a real, visible state the watchdog assigns when liveness evidence
goes stale; see data model for the shipped DDL.) The moat this protects sits one layer up, in
orchestration_edges: those edges must be persisted (written once, at ingest or
projection time) and per-instance (not type-aggregated), carrying an
instance/host key from the first migration for future fleet aggregation
(DESIGN §4, §2.4; concept-analysis-v2 CD-4). The global/cross-session DAG the SPA renders
is served by querying that table — it is never rebuilt at render time
(concept-analysis-v2 §6). Full schema treatment:
data model; the moat-specific rebuild/outage story:
the DAG moat — empirically de-risked by the
phase-0 probe, which rebuilt the depth-1 edges from
JSONL at a 0% orphan rate and recovered depth-2 edges 100% via a self-referential parent
index.
The architect lens confirms this framing directly: “the topology (hooks + JSONL → ingest → SQLite/WAL → SSE → SPA), the ports-&-adapters backbone, and the ‘hierarchy is persisted data, not UI reconstruction’ invariant are all correct and confirmed” (concept-analysis-v2 §4.1).
The design basis’s original loop diagram (§3) wrote the transport as
“WebSocket/SSE” — left open; the diagram reproduced above already shows the resolved
transport. concept-analysis-v2 resolves this: transport is SSE, with
same-origin enforcement from Phase 1 (CD-5). The realtime hub is a server→browser-only
feed; the decision explicitly defers WebSocket unless a future feature needs bidirectional
control, which nothing on the current roadmap does (concept-analysis-v2 CD-5). Same-origin
checking and the mandatory auth token apply to this channel exactly as they apply to every
other endpoint — see security model for the enforcement detail.
This SSE-vs-WebSocket resolution is one of the CD-1…CD-10 canonical decisions; the ADR
recording it in full lives under decisions
(ADR-0007). As built, /api/stream is exactly this: SSE via a hijacked reply, a
same-origin check that rejects a foreign Origin with 403 before auth, the mandatory
token (Bearer header, or ?token= for EventSource, redacted from logs), a retry:
reconnect hint and comment heartbeats. There is no resume protocol — event ids are a
per-process counter and a reconnecting client re-fetches state from the read API.
The browser SPA renders two coordinated views over the same persisted state:
simple10’s buildAgentTree() /
layoutTree() plus its dependency-free N-body force graph (physics.ts) — validated
against one real subagent-heavy session before committing (DESIGN §6). The global,
persistent, per-instance DAG — queried from orchestration_edges, not reconstructed
— is the moat feature none of the six audited projects deliver (DESIGN §2.1, §6); its
first layout extension, when needed, is ELK/Graphviz over the persisted tree (DESIGN
§6).hoangsonww’s D3 Sankey and aggregate polish are worth
studying directly as rendering technique (DESIGN §6) — but its OrchestrationDAG.tsx
is a type-aggregated 3–4 layer diagram, not true per-instance nesting; its real nesting
is a collapsible indented tree reconstructed post-hoc on SubagentStop (DESIGN §6).
agenthropic borrows the Sankey rendering idea, not the aggregation-instead-of-persistence
shortcut.Update — 2026-07 (as built). The SPA is real: a token-gated shell with four views (live status, sessions/tree, the global DAG queried from
orchestration_edges, and cost). Truncation of the capped global DAG stays visible in the UI, inferred edges are distinguishable from observed ones, and unpriced tokens surface as their own figure — never folded into a dollar total. See the dashboard.
None of the six audited projects is forked; agenthropic is greenfield (DESIGN §0). The specific source-level patterns worth reusing (DESIGN §7):
| From | Pattern | Why |
|---|---|---|
simple10 |
Ports/adapters storage + strategy-pattern agent classes; buildAgentTree()/layoutTree(); AGENTS_OBSERVE_RUNTIME=local under launchd (no Docker daemon) |
Cleanest, most portable base |
hoangsonww |
formatTelegram webhook provider; alert_rules/webhook_targets schema better-sqlite3 + node:sqlite fallback)better-sqlite3 driver) |
Easiest Telegram bridge |
cast |
controlGate.ts (~73 LOC: read-only by default, non-safe verbs 404 unless token, timingSafeEqual, mounted before router); delegation-savings analytics (~50 LOC, re-prices Haiku at Sonnet rates off ~/.claude JSONL) |
Drop-in auth gate; the cost moat (re-verify the hardcoded pricing table before trusting it) |
disler |
~180-LOC send_event.py ingest loop |
Clearest teaching example of hook→HTTP→SQLite→WS |
nirdiamant |
git stash+tag run-checkpoint |
Non-destructive session snapshots |
Per concept-analysis-v2 CD-9, simple10 and hoangsonww are copied with
attribution (their licenses permit it); cast, disler, and nirdiamant patterns are
clean-room reimplemented — all three are all-rights-reserved by Berne default, not
merely “ambiguous” (concept-analysis-v2 §4.5, Gap #4; §6). Full rule and CI enforcement:
licensing & provenance.
Update — 2026-07 (as built). Every item in this section has since resolved: ingest is JSONL-primary in code (the spike ran, CONDITIONAL-GO; numbers stay PROVISIONAL until the hand-labeled corpus ratifies them); the hook catalog is verified —
SubagentStartdoes not exist and the installer wires the four real events; the hook endpoint is authenticated — the same mandatory Bearer token, which the installed hook command never expands in the shell at all:curlimports the environment variable into its own variable space (--variable '%DASHBOARD_TOKEN') and references it from a single-quoted--expand-headertemplate, so the secret is substituted inside curl after argv is parsed and no process listing,~/.claudesettings file, or shell history ever holds it (this needs curl ≥ 8.3.0; an older curl fails at option parse and sends nothing); and the stack is locked and shipped — Fastify + TypeBox, singlebetter-sqlite3driver, React + Vite, pnpm monorepo (apps/server,apps/web,packages/shared,packages/core,packages/test-fixtures,hooks/). The list below is preserved as the honest record of what was open when this page was written.
This was written as a design-basis page before any code existed — at the time, several load-bearing details were explicitly open. Stating them rather than glossing over them:
CONDITIONAL-GO (confidence 85) by the 2026-07-04 desktop probe: the JSONL transcript
does carry the subagent parent→child linkage well enough to rebuild the DAG after a
full outage (the depth-1 edge is a hard key, 0% orphan), so v1 is JSONL-primary with
backfill, and the durable outbox/spool is pulled off the v1 critical path — a
deferrable, YAGNI-leaning fallback added only on a real trigger (a sub-second
live-freshness need, or hooks becoming a data source not also present in JSONL). The
proven load-bearing hedges are instead dual-layout parsing (85% of agents are nested) and
child-transcript token summation (parent rollup ≈ 0%). The formal Phase-0 spike
(WP-S1/WP-S5) still confirms this on the paired-capture corpus, and the WP-S7 GO gate
stands — no production code before it (concept-analysis-v2 §2, LB1; §7, G0.1;
phase-0 probe). Deep dive:
ingest & reconciliation.SubagentStart; concept-analysis-v2’s developer lens flags that
SubagentStart “is probably not a real hook” and that the documented set is actually
PreToolUse/PostToolUse/UserPromptSubmit/Notification/Stop/SubagentStop/
SessionStart/SessionEnd/PreCompact — nine, not twelve (concept-analysis-v2 §4.2).
Phase-0 gate G0.2 must enumerate the actual fired hooks before the normalizer is
committed to a design that assumes SubagentStart exists (concept-analysis-v2 §3,
CD-8; §7). Catalog detail: hook ingestion.hook-ingest endpoint itself authenticated, and how does the hook script obtain the
token without leaking it into ~/.claude scripts (concept-analysis-v2 §7, open
question 8)? The mandatory-token invariant applies to the system as a whole; the
specific mechanics for the hook-POST leg are not yet designed.better-sqlite3 — the node:sqlite fallback was dropped per best-path
§6.3), and repo structure (pnpm monorepo —
apps/server + apps/web + packages/shared + packages/test-fixtures — vs a single
package) are named leanings, not locked decisions (DESIGN §10; concept-analysis-v2
§4.2). See the roadmap for phase sequencing once these land.This page is architecture, not the security reference — see security model for the full treatment — but three facts about this specific loop are non-negotiable and shape the diagram above:
hook-ingest and every read/write endpoint bind 127.0.0.1 only, never 0.0.0.0
(DESIGN §8).claude or any subprocess driven by request input — there
is no /api/run-shaped surface anywhere in the pipeline, which is precisely the RCE
pattern hoangsonww ships and this design walks away from (DESIGN §8).token_ref into
launchd env / chmod-600 storage (concept-analysis-v2 CD-10). Remote access to the
browser SPA is via SSH port-forward or a Tailscale tunnel only — never a reverse proxy
to the open port (DESIGN §8).agents, sessions,
events_raw/events, token_usage, orchestration_edges.SubagentStart/SubagentStop handling.orchestration_edges,
rebuild-from-JSONL, the outage story.token_usage buckets, compaction
baselines, dual-pricing, delegation-savings.