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, withhooks/README.mdas its runbook. Runnode hooks/install.mjsto print the generated configuration, ornode hooks/install.mjs --out <path-to>/.claude/settings.jsonto install it. Four corrections to the page below, each verified against the repository:
- There are four real hooks, not twelve, and
SubagentStartdoes not exist. The installer wiresUserPromptSubmit,Stop,SubagentStopandPreCompact(HOOK_EVENTSinhooks/install.mjs). TheG0.2hedge below resolved againstSubagentStart; the other seven events in the twelve-event list were simply not adopted, because nothing downstream consumes them.- Hooks carry liveness ONLY, never structure. This is the biggest change from the design text below. No hook creates an agent row, asserts a parent→child edge, or writes a token row. The subagent DAG and every token count are built entirely from the
~/.claude/projects/*.jsonltranscripts — the Phase-0 probe found 0 of 463 spawn edges were hook-sourced. It follows that the absence of hook events means nothing about whether an agent ran.- Leak-free token acquisition is resolved — in two steps. The generated command references the token by env-var name only; the value is read at fire time and
install.mjsnever reads, embeds or prints it, with the POST target hard-pinned to127.0.0.1. The mechanism was revised once (2026-08, review item M-11): the first shipped shape let the shell expand${DASHBOARD_TOKEN}into curl’s argv, visible in the process table during the POST; the current shape has curl itself import the variable (--variable '%DASHBOARD_TOKEN'+--expand-header, curl ≥ 8.3.0), so the value appears in no argv at all. Details in leak-free token acquisition.hooks/is confirmed, not “leaning-unconfirmed”, and it contains no long-lived scripts: each hook is a single fail-silentcurlPOST written into the settings file. There is nohooks/dispatch.shand no dispatcher indirection.Verification no longer needs a raw
sqlite3query either:GET /api/sessions/:id/eventsserves 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.
SubagentStart/Stop are first-classagenthropic 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:
- Four events, not twelve.
hooks/install.mjswires exactlyUserPromptSubmit,Stop,SubagentStop,PreCompact.SubagentStartdoes not exist as a Claude Code hook — theG0.2hedge below resolved in the negative. The remaining seven names in the list above were not adopted.- No hook asserts structure — ever. Contrary to the paragraph above,
agents.parent_agent_idandorchestration_edgesare not built fromSubagentStopor from any other hook. They are built exclusively from the JSONL transcripts, and so istoken_usage. A hook delivery writes anevents_rawrow plus one identifier-only row in theeventsliveness timeline (session id, agent id, event type, time) — never payload content, and never a DAG or token row.apps/server/src/db/event-store.tsstates 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.
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:
Stop maps to waiting, not completed. Claude Code fires Stop at the end of
every turn, so reading it as “the session is over” would reintroduce exactly the
confident lie the status lifecycle exists to remove. waiting says what is actually
known: idle right now. If it never comes back, the watchdog ages it to unknown after
DASHBOARD_WATCHDOG_MINUTES (default 10, a PROVISIONAL constant), and unknown
is displayed as unknown rather than softened into something friendlier.SubagentStop to the wrong agent would mark a running
agent finished — the same class of bug the lifecycle removes. That rule has one
consequence worth knowing about: a subagent that starts and finishes inside a single
poll interval fires its SubagentStop before ingest has ever seen its transcript, so
the live path correctly discards the verdict. It is not lost — when ingest later inserts
that agent’s first row, the stored SubagentStop payloads for the session are replayed
against only the just-inserted agents, and only through a reconcile that refuses to
move a row already in a terminal state. Replayed evidence never outranks a later
verdict.status column. It cannot create an agent, delete one,
re-parent one, add an edge, or touch token usage. That is CD-1 enforced at the code
level, not a convention.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.
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 --removeOther flags:
--port <n>(default4317, matching the server default),--token-env <NAME>(defaultDASHBOARD_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-envwould install hooks that reference an environment variable nobody sets; and--token-envaccepts 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/eventtarget 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~/.claudeunless you explicitly point--outthere, 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
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 holdsinstall.mjs, its type declarationinstall.d.mts, andREADME.md— nothing else. The monorepo around it settled asapps/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.
~/.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.mjshandler on the hook path. Each of the four events gets one entry whose command is a self-contained, fail-silentcurlthat 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 hooktimeout, a trailing|| true, and — the part that actually keeps the session moving —"async": trueon 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-timestays because a Claude Code too old to knowasyncignores it and waits;--show-errorprints 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 theX-Agenthropic-Delivery-Idheader is double-quoted because the shell must expand$$-$(date +%s)-$RANDOMat fire time — that is what makes the id per-firing, and it carries no secret.
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:
127.0.0.1, never 0.0.0.0, under the same
Fastify bootstrap (WP-U0) that makes loopback-or-fail real for every other endpoint
(security model rule 1).timingSafeEqual(DASHBOARD_TOKEN) gate the read API and realtime stream use — there is
no weaker, hook-specific auth path (security model rule 2).202 and
a stored row; it is not validated against a fixed allowlist and never crashes the
pipeline (hook ingestion).WP-IN1’s envelope contract guarantees a hook payload
and the JSONL line describing the same fact produce a byte-identical
idempotency key, so re-registering a hook, a Claude Code retry, or the later JSONL
tail-follower reading the same fact never double-counts it in events_raw.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:
- 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.- Envelope + key (
WP-IN1) —{ source: 'hook', hookName, sessionId?, receivedAt, deliveryId?, payload }, keyed by a SHA-256 hash over the canonicalized envelope minusreceivedAt, plus the sender’s per-firingdeliveryId, 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.- One transaction, two tables —
INSERT OR IGNOREintoevents_raw, and, only when that actually inserted, one identifier-only row intoevents. A duplicate inserts zero rows in both. The reply is202with{ "stored": true|false }, wherefalsemeans “already had it”.Two honesty notes carried in the code:
occurred_aton the projected row is receipt time, because Claude Code hook stdin carries no event timestamp — the read DTO saysoccurredAtSource: '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).
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:
400, and the
validation message names the header, never its value. An unbounded client-controlled
string does not reach the hash function.stored: false and skip. Re-applying is
free — the seam is a guarded no-op when the agent already holds that status, so no
duplicate transition reaches the SSE stream. An append that throws skips it: nothing
landed, so there is nothing to say.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 inargv/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:
- the settings file on disk holds the variable name, never the value;
- the command writes to
/dev/null(--silent --output /dev/null) and prints nothing on success, so it cannot leak into a transcript;hooks/install.mjsitself never reads, embeds or prints the token — it has no code path that touches the value at all;- the server’s log serializer strips
?token=from logged URLs, and the token is never persisted or echoed.Three operational facts that come with the mechanism:
- Minimum curl 8.3.0 (
--variable/--expand-header; macOS ships a new enough curl since 14.4, current Linux distributions likewise). An older curl rejects the unknown option at parse time and sends nothing: the hook still exits 0 (|| true), a short token-free error goes to stderr, and the session is never blocked — the mechanism degrades to zero telemetry, never to a leaked token. If no hook events arrive, checkcurl --versionfirst.- Rotation is unchanged: the environment stays the only runtime source of truth — no token-bearing file is written at install time, so rotating means exporting the new value, nothing more. A settings file installed before the M-11 fix is upgraded in place by re-running the installer (entries are recognized by their loopback endpoint, not their exact command text).
- Residual exposure, honestly: the fix closes the cross-account argv window. An attacker running as the same account (or root) can always read the token anyway — from the process environment, or from whatever profile or
launchdplist exports it. No hook-command mechanism can defend that boundary; the defense there is not sharing the account.The env var name is configurable via
--token-env <NAME>(defaultDASHBOARD_TOKEN, validated as UPPER_SNAKE_CASE). How that variable gets into the environment is left to the operator — alaunchd-injected value or achmod 600dotfile sourced at login both work, exactly as D7 intended; the installer takes no position and needs none.
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:
node hooks/install.mjs --out <project>/.claude/settings.json(add--dry-runfirst to see the diff).- Export
DASHBOARD_TOKEN(≥16 characters — the server refuses to start otherwise) and start the server; it binds127.0.0.1on port4317.- Run a real Claude Code session. Submitting a prompt fires
UserPromptSubmit; finishing firesStop; a subagent finishing firesSubagentStop.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 rawsqlite3query is needed.Step 5 does not apply. A hook event and a JSONL line describing the same fact do not collapse into one
events_rawrow, because they never meet: hooks land inevents_raw+events, JSONL parses straight intosessions/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.
~/.claude settings file per the (planned)
shape above, pointing at the shared dispatcher script under hooks/.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.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.)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).events_raw row, not two — the
WP-IN1 contract this whole ingest boundary rests on
(ingest & reconciliation)./api/run-shaped spawner. The receiver stores hook payloads; it never
invokes claude or any other subprocess derived from request input. There is no
command-execution surface anywhere in this design, now or planned
(security model rule 3).127.0.0.1 only, never
0.0.0.0 — not even behind a flag, and not even because the hook script happens to run
on the same machine as a locally-installed Claude Code. Remote operators reach it only
through an SSH port-forward or Tailscale tunnel terminating at loopback, never a reverse
proxy to an open port (security model rules 1 and 7).SubagentStart hedge, and accept-any-event in depth. (As built: four events, and
SubagentStart does not exist.)WP-IN1
idempotency contract and JSONL-vs-hook precedence this installer’s output feeds into.
(As built: the envelope and its idempotency key are real; the cross-path
hook-vs-JSONL reconciliation was not built.)events_raw DDL used for
verification in step 4 above. (As built: verify through
GET /api/sessions/:id/events instead.)DASHBOARD_TOKEN and the other environment
variables the installed hooks and the server share.