agenthropic

Hooks installer

How to read this page. The installer is built and runnable — node hooks/install.mjs, four hooks, no dispatcher script. The page keeps the design-era text it was written as, before any of it existed, because the gap between what was planned and what shipped is unusually large here: the plan assumed twelve hook events feeding the subagent tree, and the built system uses four that feed liveness only. Rather than delete the wrong version, each section carries an As built box saying what replaced it and why. A step marked (planned) or (leaning-unconfirmed) is therefore design history, not an open question. The security invariants were binding then and are binding now.

Update — 2026-07 (as built). The installer exists: hooks/install.mjs, a dependency-free Node ESM script, with hooks/README.md as its runbook. Run node hooks/install.mjs to print the generated configuration, or node hooks/install.mjs --out <path-to>/.claude/settings.json to install it. Four corrections to the page below, each verified against the repository:

Verification no longer needs a raw sqlite3 query either: GET /api/sessions/:id/events serves the hook-liveness timeline of one session (see the API reference).

This page documents how the Claude Code lifecycle hooks are designed to be installed and verified end to end: where the hook scripts live, how they’re registered in Claude Code’s own settings, the authed loopback receiver they POST to, how a hook acquires DASHBOARD_TOKEN without leaking it, and how to confirm a real session actually produced an events_raw row. It was written as a target contract for one devops-owned work package, WP-X8, whose Done-when is exactly the phrase the title of this page describes: “Install → working end-to-end hook → loopback ingest → events_raw on a real session” (docs/analysis/development-plan.md). That package has since shipped, so where the text below says there is no installer yet, read it as the record of what was true when the contract was written — the installer is hooks/install.mjs and you can run it now. Background on what the hooks carry and why two of them get special treatment is hook ingestion — this page does not duplicate that catalogue, it covers getting the scripts onto disk, wired into Claude Code, and proven to work.

What the hooks are, and why SubagentStart/Stop are first-class

agenthropic wires all twelve Claude Code lifecycle events to a single hook-handler, which forwards every event, unmodified, to one authed loopback receiver (DESIGN.md §5; hook ingestion):

PreToolUse · PostToolUse · PostToolUseFailure · UserPromptSubmit · Notification · Stop · SubagentStop · SubagentStart · SessionStart · SessionEnd · PreCompact · PermissionRequest

Two of the twelve — SubagentStart and SubagentStop — get dedicated handling because they are the only events that can directly assert a parent→child relationship, and agents.parent_agent_id / orchestration_edges (the hierarchy tables — “the subagent tree is a data fact, not a client-side UI reconstruction,” DESIGN.md §3) are built from them. Everything else lands as an interim liveness/state signal that the projection later reconciles against JSONL (CD-3). The installer’s job is purely mechanical relative to that distinction — it wires all twelve the same way; the dedicated handling happens downstream in the Normalizer/Projection, not in the hook script itself.

Update — 2026-07 (as built): this whole framing was overturned. Two facts replace it:

  1. Four events, not twelve. hooks/install.mjs wires exactly UserPromptSubmit, Stop, SubagentStop, PreCompact. SubagentStart does not exist as a Claude Code hook — the G0.2 hedge below resolved in the negative. The remaining seven names in the list above were not adopted.
  2. No hook asserts structure — ever. Contrary to the paragraph above, agents.parent_agent_id and orchestration_edges are not built from SubagentStop or from any other hook. They are built exclusively from the JSONL transcripts, and so is token_usage. A hook delivery writes an events_raw row plus one identifier-only row in the events liveness timeline (session id, agent id, event type, time) — never payload content, and never a DAG or token row. apps/server/src/db/event-store.ts states the rule directly: “hooks contribute liveness only, NEVER structure.”

The practical consequence for a reader: hooks make the dashboard live, they do not make it correct. Uninstall them and the DAG and the dollar figures are unaffected — what you lose is sub-poll-interval freshness and, more visibly, any report that something finished. The next section spells that out.

One caveat that directly affects what the installer can register: SubagentStart is listed in parentheses in DESIGN.md §5 — `SubagentStop` (+ `SubagentStart`) — because the source-level pass behind the build plan flags it as “probably not a real hook” (concept-analysis-v2.md §4.2). Confirming or denying it is Phase-0’s G0.2 probe (WP-S4), and WP-X8 (the installer) depends on WP-S4 for exactly this reason — the installer can’t finalize which event names it registers until the hook catalog itself is confirmed. If SubagentStart turns out not to fire, the installer still registers the other eleven; edge derivation falls back to the JSONL Agent/Workflow spawn chain plus SubagentStop (WP-IN8, dual-path derivation), with no change to what gets installed. (As built: SubagentStart was confirmed not to exist, and edge derivation went further than the fallback described here — it is JSONL-only, with no SubagentStop contribution at all.) Full treatment of the hedge is the dedicated SubagentStart section in hook ingestion.

What the hooks actually buy you: the ability to say “finished”

This section is not in the design text; it is the as-built answer to “why bother installing these at all”, and it is the single most useful thing to understand before you decide. Reading a transcript proves that activity happened. It cannot prove that activity ended — a file that stops growing looks exactly like a file whose author is thinking. So ingest only ever writes working, and the watchdog can only ever age a silent agent to unknown. The terminal signal has to come from outside the transcript, and the hooks are the only place it comes from:

Hook What the server does with it Resulting status
SubagentStop Resolves the subagent it names — from the agent id in the payload, or failing that from an agent-<hex>.jsonl transcript path — and marks that one row finished completed
Stop Identifies the main agent (whose id is the session uuid) and records that it is idle right now waiting
UserPromptSubmit Stored, and projected onto the session’s event timeline (none)
PreCompact Stored, and projected onto the session’s event timeline (none)

Only two of the four move anything. The other two are registered because they are cheap to collect and they show up in the timeline GET /api/sessions/:id/events serves — not because anything downstream keys off them. In particular, PreCompact does not drive the cost engine’s compaction repricing: compaction boundaries are read from compactMetadata in the transcript, never from the hook, so a session with no hooks installed still reprices correctly across a compaction.

Three properties of that table are deliberate and worth stating outright:

The consequence, stated plainly: if you do not install these hooks, nothing in the dashboard will ever read done. Agents move working → unknown and stay there. That is not a defect in the board; it is the board declining to claim an ending nobody observed. Everything else — the tree, the global DAG, every token and dollar figure — is unaffected, because none of it comes from hooks in the first place.

The designed install procedure

WP-X8 (devops, size M) is scoped as one work package covering four things together: hooks/ scripts, install docs, leak-free token acquisition, and an end-to-end smoke test (development-plan.md §2, merge note 4 — it absorbs the earlier, separately-tracked WP-IN4). It depends on WP-S4 (hook catalog confirmed), WP-IN1 (envelope/idempotency contract), WP-S7 (the Phase-0 GO/CONDITIONAL-GO verdict — no production code before it, CD-8), and WP-IN3 (the receiver has to exist before there’s anything to install against). None of those four have landed as of this writing, which is exactly why this page is design-target, not a runbook you can follow today.

As built: they landed, and there is a runbook — hooks/README.md. The real commands:

node hooks/install.mjs                                   # print, write nothing
node hooks/install.mjs --out .claude/settings.json        # create or update
node hooks/install.mjs --out .claude/settings.json --dry-run
node hooks/install.mjs --out .claude/settings.json --remove

Other flags: --port <n> (default 4317, matching the server default), --token-env <NAME> (default DASHBOARD_TOKEN) — the name of the env var the generated command expands, never its value — and --help / -h, which prints the usage and exits. Two argument rules are deliberately strict rather than forgiving: an unrecognised flag is a hard error, not a warning, because silently ignoring --to-ken-env would install hooks that reference an environment variable nobody sets; and --token-env accepts only an UPPER_SNAKE_CASE name (/^[A-Z_][A-Z0-9_]*$/), because anything else would be shell metacharacters landing inside a generated command string.

Three behaviors worth knowing before you run it: the merge is non-destructive (unrelated settings keys and foreign hook entries are preserved verbatim, and previously installed agenthropic entries — recognized by the loopback /api/hooks/event target in the command string — are replaced rather than duplicated); an existing file is backed up to <file>.backup-<timestamp> before it is modified, which is also the rollback path; and the installer refuses to touch a settings file it cannot parse as JSON rather than overwriting it. It never writes to ~/.claude unless you explicitly point --out there, never spawns a process, and never opens a network connection.

~/.claude settings                              Mac Mini M4 — 127.0.0.1 only
(hook registration — planned shape below)                │
        │ Claude Code fires a lifecycle event             │
        ▼                                                 │
single hook-handler script (hooks/, planned)              │
  reads DASHBOARD_TOKEN by reference — never argv/log     │
        │ HTTP POST, one event per call                   │
        ▼                                                 │
loopback-or-fail bind · timingSafeEqual(DASHBOARD_TOKEN)  │
        │ 202 Accepted — accept-any-event (WP-IN3)         │
        ▼                                                 │
HookSource adapter → envelope + idempotency key (WP-IN1)  │
        │                                                  │
        ▼                                                 │
events_raw  (append-only, WP-D4) ◄── verify this row lands

Where the scripts live: hooks/ (planned)

The repo-structure decision itself is open (CLAUDE.md: “Stack & repo structure are an open decision”), but the leaning shape already names a home for installable hook scripts: a top-level hooks/ directory in a pnpm monorepo (implementation-plan.md, D4: hooks/ alongside packages/server/packages/web/ packages/shared; concept-analysis-v2.md §4.2 reconciles the deployables naming to apps/server + apps/web — “reconciles BASE packages/* vs EXPANDED apps/*” — while keeping hooks/ as the installable-scripts home and adding packages/test-fixtures). apps/server/apps/web is the naming used consistently elsewhere on this site (security model, development plan). Mark this hooks/ path (leaning-unconfirmed) — it is not fixed until WP-X8 ships.

As built: confirmed. hooks/ exists at the repo root and holds install.mjs, its type declaration install.d.mts, and README.md — nothing else. The monorepo around it settled as apps/server, apps/web, packages/shared, packages/core, packages/test-fixtures, hooks/.

The structural pattern the scripts are meant to follow — not copy — is simple10’s strategy-pattern separation, hooks/scripts/lib/agents/<class>.mjs, which cleanly isolates the Claude-Code-specific ingestion shape from everything downstream (DESIGN.md §3, §7; architecture overview). The single-hook-handler design (one script every event calls into, per the diagram above) is exactly this separation applied at the installer level: twelve registrations, one implementation. (As built: four registrations, and no implementation script at all — each registration is a self-contained one-line curl POST, so there is nothing on disk for a hook to call into.)

What the scripts are explicitly not: a copy of disler’s send_event.py. DESIGN.md calls that ~180-line script “the clearest teaching example of hook→HTTP→ SQLite→WS” but is equally explicit that agenthropic does not build on it — “no license, no tests, dead subagent path — its server drops agent_id/agent_type” (DESIGN.md §3, §7). disler carries no license file and is classified clean-room, teaching-reference-only under CD-9 (licensing & provenance), which is exactly why WP-X8 appears in the CD-9 coverage list (development-plan.md §6) alongside the other clean-room-authored work packages: the loop’s shape is instructive, its code is never read while writing agenthropic’s own hooks/ scripts.

Registering the hooks in Claude Code settings (~/.claude) (planned)

Claude Code reads its own hook configuration from settings under ~/.claude. The exact keys and matcher shape the installer writes into that file are not yet fixed — this is precisely the “hook-POST auth mechanics” open question’s sibling: item 8 in concept-analysis-v2.md §7 asks how the token is obtained (next section); the registration shape itself is simply undesigned until WP-X8. The design intent, per the single-hook-handler principle above, is one dispatcher entry point that every registered event name invokes — not twelve bespoke integrations:

// ILLUSTRATIVE ONLY — not a fixed shape; keys, matcher syntax, and the script path
// are all (planned), pending WP-X8. Shows intent: one dispatcher, all twelve events.
{
  "hooks": {
    "PreToolUse":        [{ "hooks": [{ "type": "command", "command": "hooks/dispatch.sh PreToolUse" }] }],
    "PostToolUse":       [{ "hooks": [{ "type": "command", "command": "hooks/dispatch.sh PostToolUse" }] }],
    "SubagentStop":      [{ "hooks": [{ "type": "command", "command": "hooks/dispatch.sh SubagentStop" }] }]
    // ... remaining nine events, same shape (SubagentStart included opportunistically —
    // see the G0.2 hedge above; its absence must not break the other eleven)
  }
}

hooks/dispatch.sh above is a placeholder name, not a fixed filename — the point the sketch makes is structural: every entry calls into the same shared script, which reads the event payload, attaches the token (next section), and POSTs to the loopback receiver. Because WP-S4 hasn’t confirmed the final hook catalog yet, the installer’s own registered event list is provisional until that probe reports — consistent with the ingest boundary itself being accept-any-event (below), a hook name the installer doesn’t yet know about is still safe to leave registered.

As built: the registration shape is fixed, and there is no dispatcher script — no hooks/dispatch.sh, no shared .mjs handler on the hook path. Each of the four events gets one entry whose command is a self-contained, fail-silent curl that pipes the hook’s stdin JSON straight to the loopback receiver:

// Generated by `node hooks/install.mjs` — shape is real, not illustrative.
// The same command string is used for all four events.
{
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "timeout": 5, "async": true, "command": "curl --silent --show-error --fail --max-time 3 --output /dev/null --request POST --header 'Content-Type: application/json' --variable '%DASHBOARD_TOKEN' --expand-header 'Authorization: Bearer {{DASHBOARD_TOKEN}}' --header \"X-Agenthropic-Delivery-Id: $$-$(date +%s)-$RANDOM\" --data-binary @- 'http://127.0.0.1:4317/api/hooks/event' || true" }] }],
    "Stop":             [{ "hooks": [{ "type": "command", "timeout": 5, "async": true, "command": "…same…" }] }],
    "SubagentStop":     [{ "hooks": [{ "type": "command", "timeout": 5, "async": true, "command": "…same…" }] }],
    "PreCompact":       [{ "hooks": [{ "type": "command", "timeout": 5, "async": true, "command": "…same…" }] }]
  }
}

Note the deliberate belt-and-braces on failure: --silent --show-error --fail, a 3-second --max-time, a 5-second hook timeout, a trailing || true, and — the part that actually keeps the session moving — "async": true on every entry, so Claude Code writes the hook JSON to curl’s stdin, backgrounds the process and continues immediately (amended 2026-09-02: until then every generated entry was synchronous, and a wedged dashboard cost up to three seconds of dead wait per firing). --max-time stays because a Claude Code too old to know async ignores it and waits; --show-error prints one line of curl’s own error text, and nothing retries or spools. A dashboard that is down, slow, or missing must never block or slow a Claude Code session — hooks are optional telemetry, and the JSONL transcripts remain the ground truth either way. The quoting split in the command is deliberate: the two token arguments are single-quoted (the shell must never expand them — curl imports the env var itself, see leak-free token acquisition), while the X-Agenthropic-Delivery-Id header is double-quoted because the shell must expand $$-$(date +%s)-$RANDOM at fire time — that is what makes the id per-firing, and it carries no secret.

The receiver they POST to: HookSource (WP-IN3)

Every hook script POSTs to the same server every other agenthropic client talks to — not a separate, less-guarded ingest port. WP-IN3 defines it precisely: “authed loopback POST receiver, accept-any-event. Never-seen event_type → 202 + a row lands (audit-preserving)” (development-plan.md). Concretely:

As built: the receiver is POST /api/hooks/event (apps/server/src/hooks/routes.ts), and every bullet above holds. What the design text does not mention, in the order it happens on each delivery:

  1. Redaction first (WP-IN14, apps/server/src/hooks/redact.ts) — secret-named fields (token, authorization, api_key, secret, password, bearer, credential, private_key, access_key, cookie…) become [REDACTED], and string values are scanned for credential shapes (sk-/ghp_/xox/AKIA, JWTs, Bearer …) and masked in place. An explicit allowlist keeps token-count fields (input_tokens, output_tokens, …) intact — counts are observability data, not credentials. Redaction runs before the idempotency key is computed, so a redelivered event redacts identically and still dedupes.
  2. Envelope + key (WP-IN1) — { source: 'hook', hookName, sessionId?, receivedAt, deliveryId?, payload }, keyed by a SHA-256 hash over the canonicalized envelope minus receivedAt, plus the sender’s per-firing deliveryId, so the same firing delivered twice at different times yields the same key regardless of JSON property order, while a second firing of an identical body gets a new key.
  3. One transaction, two tables — INSERT OR IGNORE into events_raw, and, only when that actually inserted, one identifier-only row into events. A duplicate inserts zero rows in both. The reply is 202 with { "stored": true|false }, where false means “already had it”.

Two honesty notes carried in the code: occurred_at on the projected row is receipt time, because Claude Code hook stdin carries no event timestamp — the read DTO says occurredAtSource: 'receipt' so nothing downstream mistakes it for event time. And the idempotency key is hook-scoped: it does not collapse a hook delivery against a JSONL line describing the same fact, because the two never share a table (see the verification section below).

Why each firing carries a delivery id

Content-based deduplication has a blind spot that shows up immediately with these four events: two firings may legitimately carry the same bytes, and which fields a given Claude Code version puts in a body is not a contract this project controls. (The first version of this argument said a Stop body is byte-identical on every turn; that was corrected on 2026-09-02 — a Stop body also carries prompt_id, last_assistant_message, background_tasks and session_crons, and the first two change per turn — but the blind spot stands.) Hash the payload and a repeat of an identical turn is indistinguishable from a redelivery of it — one of which must be kept and the other dropped, and the payload cannot tell you which. Only the sender knows. So the generated command stamps every firing with a header, X-Agenthropic-Delivery-Id, minted in the hook’s own shell at fire time from the shell pid, the epoch second and $RANDOM. It carries no user data, it is hashed into the idempotency key and nothing else, and it is never stored, logged or echoed back. The installer never computes a value itself, so no id is ever baked into the settings file.

Three edge behaviours follow, and all three are conservative on purpose:

Leak-free token acquisition (security-critical)

This is explicitly part of WP-X8’s scope, not an afterthought — the work package is defined as “hooks/ scripts + install docs + leak-free token acquisition + end-to-end smoke” (development-plan.md §2, merge note 4). It is also, as of this writing, an open, unresolved question (As built: resolved — see the box at the end of this section.): concept-analysis-v2.md §7, item 8, asks directly — “is the loopback hook endpoint itself authenticated, and how does the hook script obtain the token without leaking it into ~/.claude scripts?” — hook ingestion names WP-X8/this page as exactly where that gets settled. The mechanism is therefore not fixed yet; the shape it must take is already constrained by invariants this project holds everywhere else, and implementation-plan.md’s D7 decision fixes that shape for the closest analogous secret today (the Telegram bot token): “via a launchd-injected env var (or a chmod 600 dotfile the service reads at boot); never stored in SQLite, never sent to the browser.” WP-X8’s hook-token acquisition is designed to follow the same pattern for DASHBOARD_TOKEN — held by reference, never embedded as a literal value anywhere persisted, logged, or transcribed.

Concretely, what “leak-free” rules out, and why each one matters specifically for a hook (a process Claude Code itself invokes, whose command line and output Claude Code’s own machinery can observe):

Never Why it matters here
A literal token baked into the ~/.claude hook-command string That settings file is plaintext on disk, and the command string is exactly what an installer would write in the (planned) registration shape above
A literal token passed as a CLI argument argv is visible to any other process on the host via ps//proc — a classic local-secret leak vector, independent of anything Claude Code does
A token printed to the hook script’s stdout/stderr Claude Code’s own transcript (~/.claude/projects/*.jsonl) is the same ground-truth log this project treats as authoritative elsewhere (DESIGN.md §3) — anything the hook prints risks ending up captured in it
A token committed inside the versioned hooks/ scripts themselves The scripts are installable artifacts meant to be copied into ~/.claude; a secret baked into them travels with every copy

Instead, the intended shape is that the shared dispatcher script reads the token at runtime from a reference already present in its own process environment — the same launchd env / chmod-600 pattern fixed for the Telegram token by D7, and named directly in the project’s non-negotiable constraints as how every secret here is held (“never in SQLite, never in SSE, never in logs”). Any sample this page or the eventual install docs show uses a placeholder only, per the project’s own convention:

DASHBOARD_TOKEN=<token>

Never a real value — consistent with configuration and security model rule 2. Until WP-X8 actually ships this mechanism, treat the exact env var name, file path, or permission bits the hook script reads from as unresolved, not merely unconfirmed detail — this page states the intent precisely so WP-X8 implements exactly this and nothing weaker, not to imply the design is finished.

As built: resolved — in two steps, because the first attempt failed its own bar. The shape shipped 2026-07 referenced the token as a shell expansion — Authorization: Bearer ${DASHBOARD_TOKEN}, expanded by the shell at fire time — and this page originally claimed it “never appears in argv/ps”. That claim was wrong (review item M-11, fixed 2026-08): the settings file held only the variable name, but the shell expanded the value into curl’s argv, so for the up-to-3-second life of each POST the token sat in the process table — readable exactly the way the table above warns about (ps, /proc/<pid>/cmdline), and by exactly the local-multi-user attacker the token defends against.

The current command closes that window by never letting the shell touch the token: it hands curl the env var name via --variable '%DASHBOARD_TOKEN' and a single-quoted header template, --expand-header 'Authorization: Bearer {{DASHBOARD_TOKEN}}'. Curl imports the environment itself, after argv parsing, so every argv position — the hook shell’s and curl’s own — carries only the variable name and the literal {{…}} template. This is pinned by tests that simulate the shell’s expansion and assert a canary token value appears in no argv word (apps/server/test/hooks-installer.test.ts, “token argv hygiene (M-11)”).

The rest of the 2026-07 properties were true and still hold:

Three operational facts that come with the mechanism:

The env var name is configurable via --token-env <NAME> (default DASHBOARD_TOKEN, validated as UPPER_SNAKE_CASE). How that variable gets into the environment is left to the operator — a launchd-injected value or a chmod 600 dotfile sourced at login both work, exactly as D7 intended; the installer takes no position and needs none.

End-to-end verification

WP-X8’s Done-when is the same phrase this page opened with: “Install → working end-to-end hook → loopback ingest → events_raw on a real session” (development-plan.md). The Phase 2 exit gate restates the system-level version of the same check: “a hook event and a transcript line describing the same fact collapse to exactly one events_raw row… an unrecognized event type is stored, never fatal” (roadmap). The designed verification sequence — every step below marked (planned) since no installer or receiver exists yet:

As built: steps 1–4 are runnable today; step 5 tests something that turned out not to exist. The real sequence:

  1. node hooks/install.mjs --out <project>/.claude/settings.json (add --dry-run first to see the diff).
  2. Export DASHBOARD_TOKEN (≥16 characters — the server refuses to start otherwise) and start the server; it binds 127.0.0.1 on port 4317.
  3. Run a real Claude Code session. Submitting a prompt fires UserPromptSubmit; finishing fires Stop; a subagent finishing fires SubagentStop.
  4. curl -H "Authorization: Bearer $DASHBOARD_TOKEN" http://127.0.0.1:4317/api/sessions/<id>/events — a 200 with an empty array means the session is known but produced no hook events; only an unknown session id gives a 404. The API never conflates those two facts, and no raw sqlite3 query is needed.

Step 5 does not apply. A hook event and a JSONL line describing the same fact do not collapse into one events_raw row, because they never meet: hooks land in events_raw + events, JSONL parses straight into sessions/agents/ orchestration_edges/token_usage, and the idempotency key is hook-scoped. The Phase-2 exit-gate wording quoted above describes a reconciliation design that was deliberately not built — a recorded divergence, not an oversight. Dedup within the JSONL path is real and separate; dedup within the hook path is real and separate; there is no cross-path collapse to test.

  1. Install. Register the hooks in a real ~/.claude settings file per the (planned) shape above, pointing at the shared dispatcher script under hooks/.
  2. Start the server with the invariant enforced. Start the dashboard bound to 127.0.0.1 with DASHBOARD_TOKEN set — the server is designed to refuse to start at all if the token is unset (security model rule 2). See getting started and configuration once those land.
  3. Run a real Claude Code session. Exercise at least one of the twelve events — a prompt that invokes a tool fires PreToolUse/PostToolUse; a subagent-heavy session also exercises SubagentStart/SubagentStop, the pair the hierarchy tables depend on. This mirrors the same kind of session Phase 0’s own spike captures for its tree validation (roadmap, Phase 0). (As built: the installer registers four events, so the ones to exercise are UserPromptSubmit, Stop, SubagentStop and PreCompact. PreToolUse/PostToolUse are not registered, and SubagentStart does not exist — and no hierarchy table depends on any of them, because the tree is parsed from the transcript, not assembled from hooks.)
  4. Confirm a row landed. Check events_raw for a row with source = 'hook', an event_type matching what fired, and a well-formed idempotency_key (data model — the reference DDL (not yet built; only agents is fixed verbatim by the design basis): id, idempotency_key UNIQUE, source IN ('hook','jsonl'), event_type, seq, payload, received_at). The exact query surface (an admin CLI, a debug endpoint, or a raw sqlite3 check) is not designed yet — mark as (planned).
  5. Confirm idempotency once the JSONL path also exists. Once the transcript tail-follower ships alongside the hook receiver (both are Phase 2), the same fact arriving via both paths must still collapse to one events_raw row, not two — the WP-IN1 contract this whole ingest boundary rests on (ingest & reconciliation).

What this is not

See also