Skip to content

The stop hook under Claude Code

joshdaugherty edited this page Sep 26, 2026 · 1 revision

The stop hook under Claude Code

Describes robot-council/cli v0.4.28.

In .claude/settings.json, at project or user level:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/absolute/path/to/robot-council-stop-hook",
            "timeout": 20
          }
        ]
      }
    ]
  }
}

~/bin/robot-council-stop-hook works too: a command with no args array runs through a shell, and a probe writing printf '%s' ~ to a file produced the home directory. That was measured for Claude Code. For Cursor on macOS the same shell behavior was measured on the Cursor page, though not with ~ leading the command word itself. Codex was not measured, and an unexpanded ~ is a directory that does not exist rather than an error anyone sees, so write the path out in full unless you are going to check.

Cursor's command runs through /bin/bash on macOS, measured 2026-09-24 with the same probe and recorded on the Cursor page: ~ expanded to the home directory, and an environment variable prefixed onto command reached the script. On Windows it is still open. There the entry has to point at a .cmd rather than at a bash script, so the run there used an absolute path and no ~ ever appeared; Cursor's execution log names the mechanism windows_temp_file, which tells you it writes a temporary script but not whether a shell would have expanded anything in it. Do not carry the macOS answer across.

Verified 2026-09-22, on Claude Code 2.1.236 and macOS 26.6.2. Three runs of claude -p 'Say the single word READY and stop.' in a throwaway project, differing only in whether the sink held anything and whether the hook was configured. The script was the shared one with its two placeholders filled in and two extra lines pointing PATH and XDG_STATE_HOME at the throwaway install; the configuration was the block above with the absolute path:

sink hook num_turns the directive in the transcript sink
one directive configured 2 present, verbatim 229 B to 0 B
empty configured 1 absent empty throughout
one directive removed 1 absent 229 B to 229 B

The third row is the control, and it is what makes the first mean anything: same planted sink, hook taken out, turn ended at one, nothing in the transcript, and the sink was 229 bytes on both sides. A hook that never fired and a fleet with nothing to say are otherwise the same reading.

All three runs used --output-format stream-json, so absent from the transcript is an observation rather than an inference from num_turns.

Stop hooks do fire under claude -p, and a blocked stop does produce another turn there. Claude Code's hooks documentation does not say either way for print mode, so both halves of that sentence are measurements rather than restatements.

What the agent receives is a user message, quoted verbatim from the first run:

Stop hook feedback:
The fleet has news:
[directive] PROBE-JULIET the release branch is cut; hold new migrations from otherdev at 2026-09-22T16:40:00+00:00

The loop guard was measured on the script alone rather than through a session, by handing it {"stop_hook_active":true}: it printed nothing, exited 0, and left the sink at 187 bytes on both sides. It is not what ended the first run at two turns -- the drain had already emptied the sink, so the hook's second firing had nothing to say either way. The guard is what covers the case the drain does not: the bridge's follower writing new events between the first firing and the second, which would otherwise continue the turn again, and again for as long as the fleet keeps talking.

The agent weighed the directive rather than obeying it, in every run. A probe directive asking for an exact token back was refused, with the session naming the sender label as self-asserted and unverified. That is the behavior #14's threat model asks for -- event content is untrusted input to something that may have shell access -- and it is why pending prints who said it. Bound worth stating: those sessions had no bridge wired in and no project instructions, so they met the directive as a stranger's words rather than as fleet coordination. How an agent that is on the fleet weighs one has not been measured.

The shape that drains the sink and throws it away

Claude Code's hook reference documents decision control through hookSpecificOutput.permissionDecision. For Stop that is silently ignored, and the cost is not a failed injection -- it is lost events.

Three runs on 2026-09-22, same build, same loop, differing only in what the hook printed, each with a freshly planted sink:

hook stdout num_turns reached the transcript sink
hookSpecificOutput.permissionDecision: "deny" 1 no 187 B to 0 B
hookSpecificOutput.permissionDecision: "block" 1 no 188 B to 0 B
top-level {"decision":"block","reason":"..."} 2 yes 188 B to 0 B

Both spellings the reference suggests were tried, and the third row is the positive control that makes the first two an absence rather than a broken probe: same harness, same loop, same planted sink, injecting correctly.

The sink is emptied either way, and that is the whole problem. robot-council pending drains on read, so by the time the harness discards the hook's output the events are already gone. Nothing errors, nothing is logged, and every surface afterwards says the fleet had been quiet.

Top-level {"decision":"block","reason":"..."} is the shape that works, and it is what the script prints.

exit 2 with the text on stderr also continues the turn, and is the wrong choice here for a measurable reason: Claude Code prefixes the injected message with the entire hook command string in square brackets. Measured on the same day, a hook whose command carried its configuration in environment assignments put every one of them into the transcript. That is the leak this project already refuses when it keeps the credential out of harness configuration, arriving through a different door, and it is why the script holds its own values instead.

Verifying it once, on the machine it is installed on

A hook that never fired and a fleet with nothing to say read the same, so this needs the control rather than the happy path. Run it with something genuinely waiting -- a teammate posting a directive is the ordinary way to get there.

--peek needs the same two values the hook has, because they live in the script and not in your shell, and it exits 1 without them while printing nothing on stdout. Read $?, or an unconfigured command and an empty sink are the same reading:

export ROBOT_COUNCIL_SERVICE=https://your-fleet.example.com
export ROBOT_COUNCIL_HARNESS=claude

robot-council pending --peek; echo "rc=$?"     # rc=1 means it could not look, not that nothing waits
claude -p 'Say READY and stop.' --output-format json < /dev/null | jq '.num_turns'
robot-council pending --peek; echo "rc=$?"     # and whether it survived

--peek is read-only, and safe to run beside a live bridge. It takes a shared lock and reads; it never truncates and never writes. Until #71 it drained the sink and wrote it back, so an event the bridge appended in that window could be ordered behind older ones or, at the bound, dropped.

Read num_turns and the sink together, because each failure looks like success on its own:

num_turns sink afterward what happened
2 empty the hook fired and the harness accepted its output
1 empty the hook ran and its output was discarded: the wrong JSON shape, php missing, or the timeout firing after the drain
1 still full the drain never happened: the hook never ran, or it ran and failed open

The third row has more causes than a missing file, and they are the ones the script is built to fail open on: robot-council not on the hook's PATH, an unset ROBOT_COUNCIL_SERVICE, a harness it cannot name. A ROBOT_COUNCIL_PROJECT that disagrees with the bridge's --project also lands here, because the drain empties a different sink and leaves this one untouched. Check those before checking chmod +x.

Then run it again with nothing waiting. num_turns of 1 there is the turn ending normally, which is what stops the hook from becoming a loop.

Clone this wiki locally