agenthropic

Architecture overview

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, in packages/core) feeds sessions/agents/orchestration_edges/token_usage inside 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_raw therefore 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 small events liveness 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 loop in one picture

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:

  1. 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.
  2. The webhook sink is a fan-out off persisted state, not off the live event stream. It reads what 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.

End-to-end walkthrough

Reading the diagram left to right, one subagent turn produces (at minimum) this sequence:

  1. Claude Code fires a lifecycle hook (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).
  2. 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.
  3. In parallel, 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).
  4. A deterministic projection turns 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).
  5. SQLite (WAL mode) is the single persisted store both the projection writes to and every read path — API, realtime hub, webhook sink — reads from. No component holds authoritative state only in memory.
  6. The realtime transport pushes the delta to the browser SPA, which redraws the subagent DAG and the cost/token Sankey view.
  7. The webhook sink independently watches persisted state for rule matches (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 — SubagentStart does not exist; spike S4), and the as-built ingest does not route JSONL through events_raw at all. A polling corpus watcher detects change by comparing a per-session fingerprint — the main transcript’s size:mtime plus a sorted rel:size:mtime line for every sidecar artifact, all read through lstat — then the pure parser reconstructs the session, cost is computed as a halt gate, and one transaction writes the projections. There is no fs.watch anywhere; 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 in events_raw (append-only, idempotency-keyed, redacted first) plus one events liveness row. Step 7 (webhook sink / Telegram) is unbuilt, post-1.0.

Component responsibilities

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 separate events_raw-fed stage; the hook-ingest row is POST /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).

Ports & adapters

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 CorpusFs port (the JSONL reader — TokenReader’s realization), the append-only EventStorePort (hook envelopes → events_raw + the events liveness projection), the RealtimeHub (SSE fan-out), and the read-only SubstrateProvider seam for the cost-analysis endpoint. The pure parser + cost engine live in packages/core with no DB imports. AlertSink has 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:

The two invariants

Invariant 1 — tokens are ground truth

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:

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.

Invariant 2 — agents and subagents are first-class persisted entities

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).

Transport: SSE, not WebSocket

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.

Visualization surface: DAG + Sankey

The browser SPA renders two coordinated views over the same persisted state:

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.

Patterns we steal, not the repos

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 ; dual SQLite driver (better-sqlite3 + node:sqlite fallback) (dropped per best-path §6.3 — single 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.

What’s undecided

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 — SubagentStart does 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: curl imports the environment variable into its own variable space (--variable '%DASHBOARD_TOKEN') and references it from a single-quoted --expand-header template, so the secret is substituted inside curl after argv is parsed and no process listing, ~/.claude settings 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, single better-sqlite3 driver, 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:

Security boundary of this loop

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:

See also