All articles

Controlling Claude with Hook Exit Codes and JSON

A hook talks back to Claude Code in two ways: through its exit code and through JSON it prints to standard output. Knowing both lets you go from "stop this" to "stop this, and here is exactly why."

The exit code channel

Exit codes are the simple channel:

  • 0 — success. The action proceeds.
  • 2 — blocking error. The action is stopped and the hook's stderr is fed back to Claude.
  • any other non-zero — a non-blocking error. It is surfaced, but does not stop the action.

This is enough for many hooks. Exit 2 with a message on stderr and you have a working block.

The JSON channel

For structured control, a hook can emit JSON on stdout with a hookSpecificOutput object. On PreToolUse, this lets you make a permission decision explicitly:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Editing lockfiles by hand is not allowed."
  }
}

Your script builds that object and prints it:

#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path')
if [[ "$path" == *package-lock.json ]]; then
  jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Do not hand-edit the lockfile."}}'
fi

Which to reach for

Use an exit code when the answer is a plain yes or no and a stderr message is enough. Use JSON when you want a named decision like "deny" with a reason attached, so the outcome is explicit rather than inferred from a number.

The reason string matters. Whether it rides on stderr or in permissionDecisionReason, it is what Claude reads to understand the block, so write it like a note to a teammate: clear, specific, and pointing at what to do instead.

Comments

Be the first to comment.