Module 38 · 45 min

Hooks That Keep Agents Inside the Lines

You can write a hook that puts an unanswered question in front of the agent at the exact prompt that touches it, and a turn-end hook that says once when work was done and nothing was filed.

Surface
tools/hooks/*.mjs · .claude/settings.json
Workbench tag
module-38
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

A rule persuades. A hook runs. Module 09 taught the exit-code contract that makes the difference: a hook is a shell command Claude Code runs at a lifecycle event, and on most events its exit code and its JSON on standard output are read as a decision.

This module uses that contract for two jobs the agents keep failing at on their own. The first is remembering the open questions: a list shown once at session start is forgotten twenty turns later, so a question has to arrive in the turn that touches its subject. The second is filing what a round learned: an agent that changed files and wrote nothing down needs one nudge, once, at the end of the turn.

Three facts govern both hooks, and getting any one wrong turns a helper into a hazard. A bookkeeping hook fails open, always: if it cannot read its own inputs, it lets the turn proceed, because the cost of a missed reminder is a reminder. A hook that blocks has to know how not to loop, because the same event that lets it block fires again on the continuation it caused. And context injected at the moment it is needed beats a list shown once, which is why one of these hooks fires on two different events with two different rules.

flowchart TD
start["SessionStart<br/>inject blocking questions<br/>(unless source is compact)"]
prompt["UserPromptSubmit<br/>inject only trigger-matched questions"]
work["the turn runs"]
stop["Stop<br/>read the round from the last byte offset;<br/>changed files and nothing filed? block once"]
cont["Stop again, stop_hook_active true<br/>let the turn end"]
start --> prompt --> work --> stop --> cont
One session's timeline. SessionStart injects the blocking questions once; each prompt injects only the questions it touches; the Stop hook checks the round and blocks at most once; the continuation it causes carries stop_hook_active and is let through.

Both hooks are below, in the regions where the arguments live. Start with open-questions.mjs, at its compact guard and its trigger loop, then enforce-capture.mjs at the four constraints inside main, then the one file that says where the blocking hook is allowed to live.

chat-workbench/
├─ tools/
│ └─ hooks/
└─ .claude/
tools/hooks/open-questions.mjs

One script, two events. SessionStart injects the blocking questions once; UserPromptSubmit injects only what the prompt touches.

/**
* Two events, one script: put the open questions in front of the model at the
* moment they matter.
*
* `SessionStart` injects the blocking questions, once, when a session begins.
* `UserPromptSubmit` injects a question only when the prompt uses one of its
* trigger words, on every prompt, for the life of the session. A list shown
* once at startup is forgotten twenty turns later; a question that arrives in
* the turn that touches its subject is read.
*
* This hook is bookkeeping. It never blocks, it never decides, and every
* failure path exits 0 having printed nothing on stdout. See
* docs/decisions/0137.
*
* Reads: knowledge-base/_questions.md. Writes: nothing.
*/

// ... imports; REPO_ROOT and KB_DIR resolved from import.meta.url, never cwd ...

/**
* The trigger table. This is data, and it is yours to edit, add a row when you
* file a question people keep re-asking. See docs/decisions/0142.
*
* `when` is a list of lower-cased phrases matched against the prompt as plain
* substrings; no regex, so a phrase with a dot or a bracket in it means the dot
* or the bracket. `question` is a substring of the question's line in
* `_questions.md`, which is what ties the row to the question rather than
* copying it: the file stays the one place the text lives, and a row whose
* `question` matches nothing quietly injects nothing.
*/
const TRIGGERS = [
{ when: ["claude_config_dir", "config directory"], question: "CLAUDE_CONFIG_DIR" },
{ when: ["concurrent", "at the same time", "two agents"], question: "two or more SDK subprocesses" },
];

/** Sections of `_questions.md` a trigger match is allowed to draw from. */
const TRIGGERABLE_SECTIONS = ["blocking", "open"];

// ... main() reads stdin, parses the payload, and routes on hook_event_name ...

function main() {
let data;
try {
  data = JSON.parse(readStdinSync());
} catch (error) {
  // Fail open. A hook that cannot read its own input has nothing to say, and
  // saying it on stdout would corrupt the JSON channel for no gain.
  return bail("could not parse the hook payload", error);
}

const event = data?.hook_event_name;
if (event === "SessionStart") return sessionStart(data);
if (event === "UserPromptSubmit") return userPromptSubmit(data);
return bail(`not wired for ${event ?? "an unnamed event"}`);
}

/** SessionStart: the blocking questions, once per session. */
function sessionStart(data) {
// `source` is one of startup, resume, clear, compact, fork. Compaction starts
// a "session" too, so a naive injector re-injects its whole payload after
// every compaction, and the context it was saving is the context it spends.
// The same guard can be written as a matcher group per source in the config
// ("matcher": "startup|resume|clear|fork"), which keeps the process from
// spawning at all; it is read here instead so that one handler entry covers
// every source and this rule stays visible next to the reason for it. See docs/decisions/0144.
if (data?.source === "compact") return;

const questions = read(KB_DIR);
if (!questions) return;

const blocking = questions.blocking ?? [];
if (blocking.length === 0) return; // Nothing blocking is not a message worth sending.

emit("SessionStart", [
  "Blocking questions from knowledge-base/_questions.md, work is stopped until each is answered:",
  ...blocking.map((line) => `- ${line}`),
].join("\n"));
}

/** UserPromptSubmit: only the questions this prompt just walked into. */
function userPromptSubmit(data) {
const prompt = typeof data?.prompt === "string" ? data.prompt.toLowerCase() : "";
if (prompt === "") return;

const questions = read(KB_DIR);
if (!questions) return;

const pool = TRIGGERABLE_SECTIONS.flatMap((section) => questions[section] ?? []);
const matched = [];
for (const row of TRIGGERS) {
  if (!row.when.some((phrase) => prompt.includes(phrase.toLowerCase()))) continue;
  for (const line of pool) {
    if (line.includes(row.question) && !matched.includes(line)) matched.push(line);
  }
}
if (matched.length === 0) return; // No trigger, no injection. Silence is the common case.

emit("UserPromptSubmit", [
  "Open questions this prompt touches, from knowledge-base/_questions.md:",
  ...matched.map((line) => `- ${line}`),
].join("\n"));
}

/**
* Print one `additionalContext` payload for the event that actually fired.
*
* `hookEventName` must name the event this run was invoked for. A SessionStart
* payload emitted from a UserPromptSubmit run is not an error anywhere: the
* harness reads the name, sees it does not match the event in flight, discards
* the whole object, and says nothing. The hook runs, exits 0, prints what looks
* like a correct payload, and injects nothing, which is why the name is passed
* in from the caller here rather than defaulted, and why both call sites are
* one line below the branch that knows which event it is in.
*/
function emit(hookEventName, additionalContext) {
process.stdout.write(JSON.stringify({
  hookSpecificOutput: { hookEventName, additionalContext },
}));
}

// ... read() parses _questions.md into sections; bail() writes stderr only ...
The two hooks at the regions this page argues about, and the settings file that places the blocker, at tag module-38. Elisions are marked; nothing shown is paraphrased.

One script, two events, two rules

open-questions.mjs reads knowledge-base/_questions.md and routes on the event that fired. On SessionStart it injects the blocking questions once, so a session that starts with an unanswered blocker begins with that blocker in front of the model. On UserPromptSubmit it injects a question only when the prompt uses one of that question’s trigger words, on every prompt, for the life of the session.

That split is the second of the module’s three facts: a block of questions shown once at startup is forgotten twenty turns later. A question that arrives in the turn that touches its subject is read.

Both jobs answer through the same field, hookSpecificOutput.additionalContext, a string the harness feeds to the model. The trigger table is a plain constant at the top of the file, matched as substrings against the lower-cased prompt. It holds a fragment of each question rather than the question text, so the file stays the one place the words live (record 0142). It is data whoever maintains the questions is meant to edit, and the comment above it says so.

Next, the compact guard, the line that keeps the injector from spending the context it saves. SessionStart fires five ways, and one of them is compact: when a long session compacts to make room, the harness starts a fresh session and fires the event again. An injector that does not return on that source re-injects its whole block into the window compaction just cleared, every time, forever, with no error. The guard could instead be a matcher in the config that never spawns the process on a compact source. It is read in the hook so that one handler entry covers every source and the rule sits next to the three lines that explain it (record 0144). This is the failure module 37 pointed at from the other side.

Four constraints on the hook that blocks

enforce-capture.mjs is the one hook here that refuses to end a turn, and a blocking hook has four things to get right or it turns into a loop or a nag. It runs on Stop, which fires at the end of every turn.

The first constraint is the stop_hook_active return, and it is the first thing main does. That flag is true when the turn is only still running because a Stop hook already blocked it. Block again and you have the infinite self-block: Claude finishes, the hook blocks, Claude finishes, the hook blocks. As module 09 records and this repository’s own domain facts restate, the harness caps that at eight continuations and then ends the turn anyway. The cost of forgetting this line is eight wasted turns rather than a hang.

The second constraint is the byte offset: the hook remembers how far into the transcript it read last time, in a small sidecar file, and examines only the bytes after it. The offset is advanced to the current file size before the hook decides anything, which is what makes “block at most once per round” structural rather than a counter it has to keep in step. A blocked round is marked examined even as the block happens, because the block is the hook’s one statement about that round (record 0138).

The third constraint is failing open on every error, the first of the module’s three facts applied to this hook. A Stop handler that cannot read a file it needs lets the turn end, because a session held hostage to a stray brace in a Markdown file is a hook that gets disabled by the first person it happens to (record 0137).

The fourth is saying nothing when nothing changed. Underneath all four is the ground-truth question: was anything filed this round? It is answered from the newest modification time under knowledge-base/, not from the transcript, because a Bash heredoc, a subagent’s write and a person editing a page in another window are all filing and none of them appears as a tool call the transcript records. The mtime check over-reports filing, and that error points at not blocking, which is the direction record 0140 wants every error in this hook to point.

Where each hook is allowed to live

The two hooks ship in two different places, split by what they can do. open-questions.mjs is requested for the plugin manifest, both events inline, resolved with ${CLAUDE_PLUGIN_ROOT}, because a hook that only adds a sentence to a prompt is useful to anyone who installs the plugin and harmless to everyone.

enforce-capture.mjs is declared in .claude/settings.json in this repository and nowhere else, because a hook that can refuse to end a turn should live where the person it happens to can see it and switch it off. A plugin manifest’s hooks are installed by a command and diagnosed only by somebody who already knows plugins declare hooks, which is the wrong amount of searching for “why will this session not finish” (record 0141).

Both run unsandboxed at the installer’s trust level either way. The split is about consent, not safety, which is module 11’s point that a hook is a control on behaviour and never a security boundary.

Run the hooks by hand

The hooks fire when a real session reaches the event, and no keyless run can make that happen. What a keyless machine can do is pipe a payload into a hook and read what comes out, which is the certification record 0145 settles for. From the repository root in Git Bash:

git checkout module-38
# The injector, against a fixture wiki: each prints one JSON object naming its event.
HOOK_KB_DIR=tools/hooks/fixtures/kb node tools/hooks/open-questions.mjs < tools/hooks/fixtures/sessionstart_startup.json
HOOK_KB_DIR=tools/hooks/fixtures/kb node tools/hooks/open-questions.mjs < tools/hooks/fixtures/userpromptsubmit_trigger.json

The Stop hook is proved across three rounds: the first Stop sets the boundary and exits 0; a round that changed a file outside knowledge-base/ and filed nothing exits 2 with its reason on stderr; and the immediate repeat exits 0 because the round is already examined. The full sequence, with WORKBENCH_APP_DATA and HOOK_KB_DIR set so the hook writes its sidecar and reads its wiki from fixtures, is in record 0145. The observed exits are first=0, second=2, third=0.

Build

The ladder:

  1. Run it. Pipe the two injector fixtures above and read the JSON. Then pipe the compact fixture and confirm it prints nothing.
  2. Read one file. tools/hooks/open-questions.mjs, and find the one return that is the compact guard.
  3. Change one line and see it. Add a row to the TRIGGERS table for a question you care about, then pipe a prompt that uses your trigger word and watch the question come back in additionalContext.
  4. Build. Wire the Stop hook into .claude/settings.json, run a round that writes a file outside the wiki and files nothing, and read the one message it produces on stderr. Then run the round again and confirm the second Stop is silent.
The mistake most people make first

You hardcode hookEventName: “UserPromptSubmit” in the emit call, because both events reach the same function and one name looks tidier than passing it in. It works for the prompt hook and looks correct everywhere. On SessionStart the hook now emits a payload whose hookEventName is UserPromptSubmit. The harness reads that name, sees it does not match the event in flight, discards the whole object, and says nothing. The hook ran, exited 0, and printed a well-formed payload. The blocking questions never reach the session, and nothing on the terminal tells you why. Passing the name in from the caller, one line below the branch that knows which event fired, is what closes it (record 0143).

The second version of this trap is the Stop hook without the stop_hook_active check. It blocks a round, Claude continues, the turn tries to end, the hook fires again and blocks again, on and on. It is not an infinite hang: the harness caps continuations at eight and then lets the turn finish. What you see is eight wasted turns, a slow session, and a message repeated eight times, for a hook whose whole job was to nudge once. The check is one line at the very top of main, and it has to be first, because everything after it assumes the round is real (record 0138).

Does this travel?

The hook concept travels well; the config and the field names do not. The cross-harness survey in research/05 records that seven of ten surveyed harnesses have a real deterministic hook mechanism, and that they converged on the core contract: a lifecycle event, a shell command, exit code 2 to block, JSON on stdout for a richer decision. What did not converge is everything around it. The event vocabulary differs in every tool, and the JSON output fields differ too, so additionalContext and hookSpecificOutput are Claude Code’s spelling of an idea other tools spell their own way. The one exception the survey names is Copilot CLI, which reads Claude Code’s hook JSON unmodified, PascalCase event names and nested matcher structure included. The mtime-and-offset logic inside these scripts is portable Node; the declarations that wire them in are not.

Check yourself

  1. open-questions.mjs injects the blocking questions on SessionStart. Why does it return early when source is compact, and what does a reader see happen to their context if that line is removed?
  2. The Stop hook advances its byte offset before it decides whether to block, not after. Walk what would go wrong if it advanced the offset only on the quiet path, and name the harness limit that stops it becoming a true hang.
  3. One hook is requested for the plugin manifest and the other is declared only in .claude/settings.json. Which is which, and what property of the blocking hook decides that it does not travel with the plugin?