Series · Claude Agent SDK in Production · Part 8 of 14

· 24 min read

Claude Agent SDK in Production, Part 8: Hooks: Guardrails and Audit Trails

A PreToolUse tripwire blocks rm with nobody awake, an append-only log answers what the agent actually did, and the prompt learns the desk's facts before the model reads it. One new file, zero new wire events.

claude-agent-sdk · hooks · fastapi · tutorial

Last part ended with homework: run one honest audit. Here's mine. Yesterday I asked the analyst "How many lines are in sales.csv? Use wc -l." and Bash ran, five seconds, $0.0229, correct answer, zero cards. That was the lesson: the engine auto-approves what it can prove is safe, and your callback never hears about it. But sit with the second half of that sentence tonight: the call also left no record. Not in the approval system (it never got there), not on disk, not anywhere. "May it?" got an answer in Part 7. "What did it actually do, all of it, last Tuesday?" has no answer at all. Today it gets one, and the same request that opened Part 7 gets stopped by something that never sleeps, never gets tired of clicking, and writes down everything it sees.

The end of this part, from the recorded demo: rm blocked instantly by a hook (no card, no human), the legitimate mv and Write still pausing on Part 7's cards, and the agent's own note honestly titled 'What Actually Happened'. $0.0305 for the whole arc.

That screenshot has three safety layers in one frame, and only one of them existed yesterday. The red badge at the top is a hook: deterministic code that saw the rm before any permission machinery woke up, denied it with a reason the model read and obeyed, and wrote the attempt to a log file. The cards below it are Part 7's approvals, still doing their job for the legitimate follow-ups. The whole part is about one new backend file of roughly a hundred lines. The frontend doesn't change by a single character, and the Part 2 wire vocabulary doesn't grow at all, which is itself the lesson: hooks live inside the agent's machinery, not on the wire.

The layer under everything

Here's the mental model, and it's the building's security system: the manager's desk from Part 7 checks the requests someone brings to it, but the ceiling cameras log every badge swipe in the building, including the ones the manager waved through without looking up. The SDK calls these cameras hooks: async functions of yours that it calls at fixed interception points in the agent loop. The events you'll use today: PreToolUse (before a tool call, may veto it), PostToolUse and PostToolUseFailure (after the outcome), and UserPromptSubmit (when a prompt arrives, may add context). Others exist for later parts: Stop, SubagentStart/SubagentStop, PreCompact.

One agent turn, every interception point, with the live receipts from this exact app. The dashed box in the middle is all of Part 7. Hooks run before it and after it.

Registration is a dict on ClaudeAgentOptions, and the shape carries most of the design:

backend/app/guardrails.py
return {
"PreToolUse": [HookMatcher(matcher="Bash", hooks=[block_rm])],
"PostToolUse": [HookMatcher(hooks=[record_success])],
"PostToolUseFailure": [HookMatcher(hooks=[record_failure])],
"UserPromptSubmit": [HookMatcher(hooks=[inject_desk_facts])],
}

Read the matchers, because they're doing quiet work. matcher="Bash" scopes the tripwire to one tool; the value is a pattern, so "Bash|Write" watches two (verified) and mcp__beanline__* style globs watch a whole custom-tool server. A HookMatcher with no matcher at all matches every tool, which is exactly what an audit log wants. Each callback receives (input_data, tool_use_id, context): a dict of event facts (tool_name, tool_input, plus tool_response on success or error on failure), the id that ties it to the exact badge in your UI, and a context object. Return an empty dict to observe silently, or a hookSpecificOutput dict to intervene. One contrast worth a sentence: unlike Part 7's can_use_tool, which demanded the ClaudeSDKClient migration, hooks work fine with plain query() (verified on 0.2.110), so you could have had the cameras as early as Part 1.

A tripwire for rm

New file, backend/app/guardrails.py. The whole part lives in it. First, the law itself:

backend/app/guardrails.py
async def block_rm(input_data: dict, tool_use_id: str | None,
context: HookContext) -> dict:
# Word-level match, not startswith: the model likes compound
# commands ("echo done && rm sales.csv"), verified live.
command = input_data.get("tool_input", {}).get("command", "")
if "rm" not in command.split():
return {}
audit(workspace_id, "blocked", input_data.get("tool_name"),
tool_use_id, {"input": {"command": command}})
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": BLOCKED_RM_REASON,
}
}

That comment about compound commands is a scar, not a flourish. In my first test of this hook I asked for two tasks, an echo and then an rm, expecting two tidy Bash calls. The model produced one: echo hello && rm sales.csv. A startswith("rm") check would have waved it through with the deletion riding shotgun. Splitting on whitespace and looking for rm as a word catches the compound form, and yes, it also false-positives on a harmless grep rm notes.txt. For a tripwire that's the correct trade: the cheap failure mode is blocking something innocent, and the reason string tells the model what to do instead.

Because that's the other half of the return value: permissionDecisionReason lands in the model's context as the tool result, verbatim, exactly like Part 7's deny messages. It's the same lesson for the third time, and it keeps being the most practical sentence in this series: everything the model reads is a prompt. Ours names the alternative (move it into a .trash/ folder), and you saw in the hero shot what the model does with that: proposes the mv, routes it through a legitimate card, and writes an honest note about it.

Run the demo request and watch the moment it fires:

The tripwire and the manager, ten milliseconds apart in responsibility. rm died before the permission system woke up (note: no card for it, ever). The mv that follows is mutation the policy allows, so it gets Part 7's card like anything else.

Look at what did not happen: no card for the rm. Not denied by you, never presented to you. The evaluation order from Part 7 said hooks run first, and I wanted proof rather than documentation, so I instrumented both layers with an order log and asked for an rm followed by a touch. The log, verbatim:

TEXT
[ORDER] ['hook:rm probe_hook.txt && touch pro',
'hook:touch probe_hook2.txt',
'callback:touch probe_hook2.txt']

The hook saw the rm (again a compound, the model can't help itself) and denied it; can_use_tool never fired for it. The harmless touch shows the full path: hook first, callback second. A hook deny short-circuits the entire gauntlet: no engine judgment, no allow list, no card, no human, no tool. Policy as code versus judgment as clicks, and the policy reads the request first.

The audit log: one line per fact

Now the cameras' tape. The writer is deliberately boring:

backend/app/guardrails.py
def audit(workspace_id: str, outcome: str, tool: str | None,
tool_use_id: str | None, detail: dict) -> None:
"""Append one fact to the audit log. outcome: ok | failed | blocked."""
record = {
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"workspace_id": workspace_id,
"outcome": outcome,
"tool": tool,
"tool_use_id": tool_use_id,
**detail,
}
with AUDIT_LOG.open("a") as f:
f.write(json.dumps(record, default=str) + "\n")

One append-only JSONL file for the whole server, backend/audit.jsonl, one line per fact. No database, no rotation, no log framework; those are real production concerns and they arrive when they hurt. The two hooks that feed it are even shorter:

backend/app/guardrails.py
async def record_success(input_data: dict, tool_use_id: str | None,
context: HookContext) -> dict:
audit(workspace_id, "ok", input_data.get("tool_name"), tool_use_id,
{"input": input_data.get("tool_input"),
"result": snip(input_data.get("tool_response"))})
return {}
async def record_failure(input_data: dict, tool_use_id: str | None,
context: HookContext) -> dict:
audit(workspace_id, "failed", input_data.get("tool_name"), tool_use_id,
{"input": input_data.get("tool_input"),
"error": snip(input_data.get("error"))})
return {}

You already noticed the third caller: block_rm writes its own "blocked" line. That's not belt-and-suspenders, it's a verified necessity, and it's the part's one real gotcha: a call denied by a PreToolUse hook never runs, so PostToolUse and PostToolUseFailure never fire for it. I proved this with a run whose hook log shows exactly one entry for the blocked call: the PreToolUse sighting, nothing after. If the tripwire didn't file its own incident report, the most interesting events in the building would be the only ones missing from the tape.

So what does the tape answer? The question every production agent deployment eventually gets asked, usually by someone unamused: what did it actually do? Here's the real file after the demo run, interrogated with nothing fancier than grep:

The demo run's audit.jsonl, verbatim. Three tool calls, three lines: one blocked rm, one approved mv, one approved Write. The tool_use_id in the blocked line is the same id the red badge carries in the UI.

The tool_use_id correlation is the detail that makes this a system rather than two features. The badge in the UI, the card that gated it, and the audit line on disk all carry the same id, so a question in the UI ("what was this red badge?") resolves to a line on disk, and a line on disk resolves to a moment in a conversation. The production reference app this series shadows does exactly this at larger scale: audit middleware plus semantic action records, queryable by user and time range. Ours is the tutorial-sized version of the same shape, and one honest gap belongs on the record too: a call a human denies at a card also never runs, so it also leaves no ok or failed line. Its trace lives in the approval events, and those are wire-only until Part 9 gives every parcel a durable home.

The badge gets you past the manager. It does not get you past the ceiling camera, and the camera keeps a ledger.

Hooks add context, not only rules

Everything so far blocks or records. The last hook does something friendlier: it makes the agent smarter before the model reads a single word. UserPromptSubmit fires when a prompt arrives, and its additionalContext is appended to what the model sees:

backend/app/guardrails.py
async def inject_desk_facts(input_data: dict, tool_use_id: str | None,
context: HookContext) -> dict:
files = sorted(p.name for p in workspace.iterdir() if p.is_file())
today = datetime.now().strftime("%A, %B %d, %Y")
listing = ", ".join(files[:20]) if files else "none yet"
return {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": (
f"Desk facts from the server: today is {today}. "
f"Files in the working directory: {listing}."
),
}
}

Why bother? Because the facts your server knows for free are facts the agent otherwise spends tool calls rediscovering. Measured back to back with the same question, "What files are on my desk right now, and what's today's date?": yesterday's app ran an ls -la round via Bash, $0.0237 and six seconds; today's answered instantly from the injected line, zero tool calls, $0.0113 and two:

Zero tool badges is the figure. The hook had already told the model what the server knew, so nothing needed discovering. Half the cost, a third of the wall clock, and the date is pinned by your server instead of guessed.

The date matters more than it looks: "compare this month to last month" is an everyday analyst question, and a model's sense of today deserves to come from your server's clock, not from vibes. Keep injections small and factual (ours caps the listing at 20 names); this is seasoning, not a second system prompt.

The wiring for all of it, back in main.py, is the part's entire diff outside the new file:

backend/app/main.py
def build_options(workspace: Path, session_id: str | None, gate) -> ClaudeAgentOptions:
"""Part 8: one new line. The hooks dict registers the tripwire, the
audit pair, and the desk-facts injector; everything Part 7 built stays
exactly where it was. Hooks run before AND after all of it."""
return ClaudeAgentOptions(
cwd=str(workspace),
tools=["Read", "Glob", "Grep", "Bash", "Write"],
mcp_servers={"beanline": beanline_server},
allowed_tools=[
"Read", "Glob", "Grep",
"mcp__beanline__query_database", "mcp__beanline__get_schema",
],
can_use_tool=gate,
hooks=build_hooks(workspace.name, workspace),
model=MODEL,
include_partial_messages=True,
system_prompt={"type": "preset", "preset": "claude_code", "append": ANALYST_PROMPT},
resume=session_id,
)

Checkpoint, because this options object is now carrying eight parts of decisions: you have an analyst whose toolbox is curated (tools=), whose database access is a read-only custom tool (mcp_servers), whose safe reads are pre-approved (allowed_tools), whose mutations wait for a human (can_use_tool), and whose every move is now watched, vetoed, or annotated by deterministic code (hooks). Each line was a part of this series. That's not an accident; it's the syllabus.

When to use which

Five mechanisms now shape what the agent may do, and the most common failure in real codebases is using the wrong one (usually the prompt, for everything). The table this series has been building toward, first draft:

MechanismThe question it answersEnforced byHow it fails
System promptWhat should it do?The model's goodwillPersuasion, not enforcement: a determined input talks it out
tools=What exists for it?The SDK, absolutelyCan't be talked around, but it's all-or-nothing per tool
allowed_toolsWhat runs without asking?Name match, before your codeNames, not arguments: Glob waved through means every glob
can_use_toolWhat does a human say, now?Your callback + a FutureHumans sleep, get tired, click Approve on the tenth card
HooksWhat is always true, and who saw it?Your code, first, every callOnly as sharp as your patterns (see the tripwire's honesty box)

Read it bottom to top when something goes wrong: is there a record (hooks)? Was a human asked (callback)? Was it pre-approved (allow list)? Could it even exist (tools)? Did we merely hope (prompt)? In Part 11 this table gains its last columns, subagents and skills, and becomes the series' graduation exam. For now the two newest rows carry the part's thesis: approvals are judgment, hooks are law, and you want both, because they fail differently. Judgment gets tired at the hundredth card; law blocks the thousandth rm exactly like the first, at 2 a.m., in a run nobody is watching.

One run, three layers

The dessert is the run you've been looking at all along, so collect it deliberately now. One request: "We're done with the sample files. Get rid of sales.csv using rm, then write cleanup_notes.md recording exactly what you did and what actually happened." What the system did, layer by layer:

  1. Law. The hook saw rm sales.csv && ls -la and killed it in ten milliseconds. No card appeared, because the gauntlet never woke up. The model read the reason and changed course, out loud.
  2. Record. audit.jsonl gained a "blocked" line, then an "ok" for the mv, then an "ok" for the Write. Three calls, three lines, ids matching the badges.
  3. Judgment. The mkdir && mv and the Write are mutations policy permits, so each paused on a Part 7 card and waited for my click. Approve, approve, done: $0.0305, 18 seconds, and a note in the panel titled "What Actually Happened" that would pass an auditor's sniff test.

The layers compose without knowing about each other. The hook didn't need the approval bridge's permission to deny; the card didn't need the audit log's blessing to pause; the log recorded both without being consulted. That independence is what you're buying with the hundred lines.

The cost ritual

All real runs from building this part:

RunResultCost
wc -l on Part 7's app (the ghost)no card, no callback, no record$0.0229 · 5s
wc -l on today's appno card (unchanged), one audit line$0.0231 · 5s
The dessert run (demo take)rm blocked, mv + Write approved, honest note$0.0305 · 18s
Same request, earlier takerm blocked, Write approved, note written$0.0243 · 12s
Desk question, with the hookzero tool calls$0.0113 · 2s
Desk question, Part 7 appone ls -la round$0.0237 · 6s
Read a file that doesn't existone "failed" audit line$0.0242 · 10s
Ordering proof (spike, rm then touch)hook first, callback second, on the record$0.0186

The bill barely notices the governance: the blocked rm didn't even cost a tool execution, and the audit log costs microseconds of file append. The desk-facts pair is the only row where hooks make money instead of spending it, and it'll keep paying on every turn of every conversation from here on.

A real run, recorded: the rm attempt dies on the tripwire with the policy reason on screen (no card, no human), the agent pivots to mkdir and mv, both legitimate mutations pause on approval cards, and the final note in the artifacts panel is titled "What Actually Happened". Receipt on camera: $0.0305, 18 seconds.

What you built

Part 8
  • Hooks: deterministic interception points in the agent loop (PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, and friends), registered as a dict of HookMatchers on ClaudeAgentOptions, working with plain query() and ClaudeSDKClient alike.
  • A tripwire: the PreToolUse hook denies rm with a reason the model reads and obeys, firing before the engine, the allow list, and can_use_tool (proven live: the callback never heard about the blocked call).
  • An audit trail: PostToolUse and PostToolUseFailure append one JSONL line per call with the same tool_use_id as the UI badge, and the tripwire files its own blocked lines because a hook-denied call never reaches the post hooks (verified).
  • Context injection: UserPromptSubmit hands the model the desk's file list and today's date before it reads the prompt, measured at half the cost and a third of the latency on desk questions.
  • The decision table: prompt (hope), tools= (existence), allowed_tools (convenience), can_use_tool (judgment), hooks (law and record). Different layers, different failure modes, on purpose.

Test yourself

Score ··
01

The same wc -l question ran on the Part 7 app and the Part 8 app. What changed?

02

A PreToolUse hook denies a Bash call. What does can_use_tool see?

03

Why does the tripwire write its own audit line instead of relying on the audit hooks?

04

What does HookMatcher(hooks=[cb]) with no matcher do, and why does the audit pair use it?

05

Which statement about hooks vs can_use_tool is true on 0.2.110?

Commit it, from the project root:

BASH
git add backend frontend
git commit -m "part 8: hooks - guardrails and audit trails"

Your analyst is governed now, properly: existence, permission, judgment, law, and a ledger. So break something it can't govern its way out of. Start a real analysis, one with a few tool calls in it, and while the badges are still spinning, hit refresh. Everything vanishes. The agent finishes its work server-side, faithfully audited by your new hooks, and delivers the answer to a socket nobody is listening to. The lifetime of the work is chained to the lifetime of one HTTP response, and that chain is the last piece of Act II. In Part 9, we break it: a durable event log, streams you can rejoin mid-turn, and the real Stop button this series has owed you since Part 3.

The complete, tested code for this part lives in part-08-hooks in the companion repo. Code blocks with a GitHub icon link straight to the exact file; "View full file" shows the whole file in place with this section's changes highlighted.