Skip to content

Hook-based Claude Code ingest via spawner-injected --settings #7

@willwashburn

Description

@willwashburn

Context

Builds on #2 and #3. Since relay and workforce always control the Claude Code spawn, burn can install hooks per-invocation via --settings rather than mutating the user's ~/.claude/settings.json globally. This is the production path for new sessions; delta-math from #2 remains the fallback for historical sessions.

Verified:

  • claude --settings <file-or-json> accepts a JSON string inline (not just a path).
  • claude --session-id <uuid> pre-assigns the session UUID so it's known to the spawner before Claude boots.

Why hooks

Claude Code's hooks fire with the full payload the hook needs — PostToolUse carries tool_response content verbatim. Confirmed in lazyagent's parser (internal/claude/parser.go:58-61). That removes the delta-math guesswork for per-tool-call sizing:

  • PreToolUse → tool name, inputs, wall-clock start, tool_use_id
  • PostToolUse → exact response bytes, duration, success, tool_use_id correlation
  • PostToolUseFailure → distinct failure path
  • UserPromptSubmit → clean turn boundary
  • SubagentStop → clean subagent boundary
  • SessionStart / SessionEnd → lifecycle

Design

A. New ingest command

burn ingest --runtime claude [--quiet]

Reads a hook payload JSON from stdin, parses, appends to the ledger. Session ID comes from the payload (session_id field); no need to thread it as a flag. Idempotent via the dedup index from #4.

Hook script is a one-liner — it just execs burn ingest --runtime claude. See lazyagent's scripts/claude-hook.sh for the pattern.

B. Settings builder for spawners

New export from @relayburn/spawner (new package, or folded into @relayburn/cli):

import { buildClaudeHookSettings } from '@relayburn/spawner';

const { sessionId, settings } = buildClaudeHookSettings({
  // optional: override the burn binary path (defaults to locating 'burn' on PATH)
  burnBin?: string,
});
// settings is a JSON string like: '{"hooks":{"PostToolUse":[{...}], ...}}'
// sessionId is a pre-generated UUID

Spawner usage:

const { sessionId, settings } = buildClaudeHookSettings();

// Stamp metadata against the session BEFORE claude starts.
// Works because @relayburn/ledger.stamp already handles stamp-before-turn.
await stamp(
  { sessionId },
  { workflowId, agentId, persona, tier }
);

// Spawn with pre-assigned session ID and injected hook settings.
spawn('claude', [
  '--session-id', sessionId,
  '--settings', settings,
  ...existingArgs,
]);

No new env vars, no files written to disk, no mutation of ~/.claude/settings.json.

C. Hook events to register

MVP subset (everything the existing reader uses):

  • PostToolUse — the load-bearing event. Gives per-tool-call output size directly.
  • PreToolUse — timestamps and tool_use_id for duration math.
  • UserPromptSubmit — turn boundaries.
  • SubagentStop — subagent tree closure (pairs with #B).
  • SessionEnd — final flush marker.

Defer Stop, Notification, PostToolUseFailure to v0.2 unless there's a specific signal they enable.

Integration points

workforce — spawn site at packages/cli/src/cli.ts:579. Args builder at packages/harness-kit/src/harness.ts:69-153. Extend buildInteractiveSpec to append --session-id and --settings when the harness is claude.

relay — two spawn sites:

  • packages/sdk/src/workflows/process-spawner.ts:68 (non-interactive agent steps)
  • packages/sdk/src/workflows/runner.ts:5887 (interactive agents via relay.spawnPty)

Both need the same treatment: call buildClaudeHookSettings(), pass flags through, stamp metadata against the returned session ID. Relay's existing env-var threading (AGENT_NAME, RELAY_API_KEY, AGENT_CHANNELS at runner.ts:5863-5868) is orthogonal — leave it alone.

Migration note: relay already has a wall-clock-based token estimator at src/cost/tracker.ts writing to ~/.agent-relay/usage.jsonl. Once this issue ships, that file becomes burn's ledger and CostTracker retires. Out of scope for this issue — file a follow-up in relay for the retirement.

Acceptance

  • buildClaudeHookSettings() returns a valid {sessionId, settings} pair; spawning claude with those flags starts a session that streams hook payloads to burn ingest.
  • A round-trip test: spawn, run one tool call, exit. Ledger contains at least one TurnRecord from the hook path with an exact tool_response byte count recorded.
  • Re-running the same session (via --resume <session-id>) appends new turns without duplicating existing ones (relies on Reader infrastructure: incremental cursors and git-canonical project keys #4's dedup).
  • Ledger entries from the hook path and the JSONL-reader path for the same session are reconcilable — same sessionId, same messageId, no double-count. (Prefer hook data when both present.)
  • Stamped metadata (workflowId, agentId) attaches correctly to all turns of the session when queried.

Depends on

Unblocks

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions