Claude Code Hooks: Update a Board Automatically When Work Finishes

What Claude Code hooks are, how they answer back, and two small hooks that make sure a commit never ends a session without a line on the task board.

7 min read

Claude Code hooks are commands you register in a settings file that Claude Code runs at fixed moments: when a session starts, before or after a tool call, when Claude is about to stop. Unlike an instruction in CLAUDE.md, which Claude tries to follow, a hook always runs. That makes hooks the right place for “never finish without updating the board”. The catch is that a hook does not know which task the work belonged to, so the reliable pattern is for the hook to notice the moment and tell Claude, and for Claude, which does know, to write the update over MCP.

What a hook is

A hook has three parts: an event, an optional matcher, and a handler. The Claude Code hooks reference (opens in a new tab) lists the events, among them SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop and SessionEnd. The matcher narrows an event: for tool events it filters on the tool’s name, so Bash or Edit|Write match those tools exactly, and anything with other characters is read as a regular expression. MCP tools are named mcp__<server>__<tool>, so a board connected as fenbs exposes mcp__fenbs__fenbs_comment.

  • Handlers come in five types: command (a shell command that receives the event as JSON on standard input), http, mcp_tool (call a tool on a connected MCP server), prompt and agent.
  • Hooks live under the hooks key of a settings file (opens in a new tab): ~/.claude/settings.json for every project, .claude/settings.json to commit with the repository, .claude/settings.local.json for yourself only.
  • Type /hooks inside Claude Code to see every configured hook and where it came from. The menu is read-only; you edit the JSON, and edits are normally picked up without a restart.
  • Command hooks run with your own user permissions. Read any hook before you add it, including ones committed by someone else.

How a hook answers back

What a command hook prints and how it exits decide what happens next. Exit 0 is success; if the output starts with { and ends with }, Claude Code reads it as JSON. Exit 2 is a blocking error, and on a Stop event it prevents Claude from stopping. Any other exit code is a non-blocking error: the action goes ahead and the transcript shows a hook error notice.

The field that matters for a board is additionalContext. On PostToolUse it is added to Claude’s context beside the tool result. On Stop it keeps the conversation going so Claude can act on it, labelled as hook feedback rather than as an error. Two protections stop that from looping for ever: the stop_hook_active field in the input is true when Claude is already continuing because of a stop hook, and Claude Code ends the turn after stop hooks have continued it eight times in a row.

Why the hook asks instead of writing

It is tempting to have the hook post to the board itself. Three things argue against it. The hook knows a commit happened but not which task it was for; Claude does. A fenbs board is reached over MCP, with a browser sign-in or a token you issue by hand under Settings, and the fenbs REST API is not available yet, so there is no simple endpoint for a script to call with a key. And although the mcp_tool handler can call a tool on an MCP server (opens in a new tab) the session has already connected, its arguments are fixed in the settings file, filled only from the hook’s own input, so it cannot pick the right task either.

So the division of labour is: the hook guarantees the moment, Claude supplies the judgement. The board write then goes through the same connection, the same role and the same history as every other change Claude makes.

Hook one: a nudge after every commit

The smallest useful hook fires after Claude runs git commit and adds one sentence to its context. The if field uses permission-rule syntax to narrow a Bash hook to matching commands, so it stays quiet for every other shell command.

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit *)",
            "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"You just committed. If this belongs to a fenbs task, comment the commit on it and move it on.\"}}'"
          }
        ]
      }
    ]
  }
}

This is often enough on its own: Claude sees the reminder the moment the commit lands, while it still knows what the commit was for. It does nothing, though, if Claude never commits, or commits and then forgets the reminder three tool calls later.

Hook two: do not stop with unrecorded work

The stronger version tracks state. After a commit it leaves a marker file; after a board comment or update it removes it; when Claude is about to stop, it checks for the marker and, if one is there, sends Claude back once to record the work. One script handles all three events.

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "if": "Bash(git commit *)",
                    "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/board-reminder.sh" }]
      },
      {
        "matcher": "mcp__fenbs__fenbs_comment|mcp__fenbs__fenbs_update_item",
        "hooks": [{ "type": "command",
                    "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/board-reminder.sh" }]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command",
                    "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/board-reminder.sh" }]
      }
    ]
  }
}
.claude/hooks/board-reminder.sh
#!/bin/bash
input=$(cat)
event=$(echo "$input" | jq -r '.hook_event_name')
session=$(echo "$input" | jq -r '.session_id')
marker="${TMPDIR:-/tmp}/claude-unrecorded-$session"

case "$event" in
  PostToolUse)
    if [ "$(echo "$input" | jq -r '.tool_name')" = "Bash" ]; then
      touch "$marker"      # a commit was made
    else
      rm -f "$marker"      # a board tool was called
    fi
    ;;
  Stop)
    [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0
    if [ -f "$marker" ]; then
      rm -f "$marker"
      jq -n '{hookSpecificOutput: {hookEventName: "Stop",
        additionalContext: "You committed work in this session and have not recorded it on the fenbs board. Find the task (fenbs_search), comment the commit and what changed (fenbs_comment), and move it to done with fenbs_update_item if it is finished. If no task fits, create one."}}'
    fi
    ;;
esac
exit 0
  • The marker is named after the session id, so two sessions in the same repository do not clear each other’s reminders.
  • The stop_hook_active check and the removal of the marker before replying mean Claude is sent back at most once per commit. If it still does not record the work, the session ends normally and nothing loops.
  • It uses jq, as the examples in Anthropic’s hooks guide (opens in a new tab) do. Make the script executable with chmod +x. On Windows without Git Bash, write the same logic in PowerShell and add "shell": "powershell" to each hook entry.
  • done is the lane’s value in the MCP tools; on the board it shows as Completed.

Check that it fires

  1. Open /hooks and confirm the two PostToolUse groups and the Stop hook appear, labelled Project Settings.
  2. Pipe a fake event into the script to test it outside Claude Code: echo '{"hook_event_name":"Stop","session_id":"t","stop_hook_active":false}' | .claude/hooks/board-reminder.sh should print nothing, because no commit has been made.
  3. Ask Claude to make and commit a small change without mentioning the board. Before it finishes you should see the hook feedback, then a fenbs_comment call.
  4. If nothing happens, start Claude Code with claude --debug and read the log it writes under ~/.claude/debug/; each hook run is logged with its output.

A reminder at the start, too

The same idea works at the other end of a session. A SessionStart command hook can print a line such as “Before starting, call fenbs_get_context for this project and list what is In Progress”, and Claude Code adds plain-text output from that event to Claude’s context. Use a command hook here rather than mcp_tool: the reference notes that an mcp_tool hook on SessionStart is skipped when you launch claude, because the session’s MCP servers are not yet available to hooks, though it does run after a /clear in the same session. Keep it short; SessionStart runs on every new, resumed and cleared session.

What hooks do not replace

A reminder hook makes recording reliable; it does not decide what Claude is allowed to do on the board. That is the job of the role and the scopes its connection holds, which fenbs checks on every call whatever the hook says. If you want a hook that refuses an action rather than prompting one, a PreToolUse hook that exits 2 does that; the project manager subagent uses one to stop a triage agent moving tasks between lanes.

Nor does a hook replace the standing instructions. The rhythm of reading the board at the start and writing to it at the end belongs in CLAUDE.md, as described in a task-tracking workflow for Claude Code. The hook is the backstop for the one step people most often find missing afterwards.

Related

Connect the board first: Claude Code and fenbs and the MCP setup page. For work that runs without you watching, see Claude Code background tasks and scheduled tasks.

Questions people ask.

Where do Claude Code hooks go?

Under the hooks key of a settings file: ~/.claude/settings.json for all your projects, .claude/settings.json to share with everyone who clones the repository, or .claude/settings.local.json for yourself only. Skills and subagents can also carry hooks in their frontmatter.

Can a hook write to the board without Claude?

Not usefully for fenbs today. The board is reached over MCP with a sign-in or a hand-issued token, the REST API is not available yet, and a hook does not know which task the work belonged to. Having the hook remind Claude, which does know, is simpler and records the change under the assistant’s own name.

How do I stop a Stop hook from looping?

Check stop_hook_active in the input and exit 0 when it is true, and clear whatever condition triggered the hook before you reply. Claude Code also ends the turn after stop hooks have continued it eight times in a row.

Do hooks run in claude -p?

Yes. In an interactive session hooks wait until you trust the folder; in a -p or SDK session there is no trust dialog, so hooks committed in a repository run straight away. Review a repository’s .claude settings before scripting it.

Start with one thing.

There is nothing to set up first. Write one line and you’ve started.