# Claude Code hooks for code review

URL: https://codepanel.ai/agents/claude-code/hooks

A Stop hook that keeps Claude Code working while CodePanel's merge gate has not passed on the pull request's head commit, tested on each path it can take.

A review loop stalls when Claude ends its turn while the review is still running. Nothing brings the session back, so the findings wait for a person. A Claude Code Stop hook catches that moment. Before Claude stops, it reads the `CodePanel` commit status on the current commit, and if the gate has not passed it sends Claude back once with the next step.

## The hook

```bash title=".claude/hooks/codepanel-gate.sh"
#!/usr/bin/env bash
# Keep Claude working while CodePanel's gate has not passed on this branch's PR.
# Use it only where the gate is on: with no CodePanel status, every stop blocks once.
input=$(cat)
# Already continuing because of a Stop hook: let the turn end, so it never blocks twice in a row.
[ "$(jq -r '.stop_hook_active // false' <<<"$input")" = "true" ] && exit 0
[ "$(gh pr view --json state --jq .state 2>/dev/null)" = "OPEN" ] || exit 0
sha=$(git rev-parse HEAD)
# If GitHub cannot be read, let the turn end rather than send Claude after a guess.
status=$(gh api "repos/{owner}/{repo}/commits/$sha/status?per_page=100" \
  --jq '([.statuses[] | select(.context == "CodePanel")][0] // empty) | "\(.state): \(.description)"') || exit 0
case "$status" in
  success:*) exit 0 ;;
  "") why="CodePanel has not judged $sha. Push it if needed, then call wait_for_review with headSha $sha and timeoutSeconds 45." ;;
  pending:*) why="CodePanel on $sha is $status. Call wait_for_review with headSha $sha and timeoutSeconds 45, then act on payload.nextAction." ;;
  *) why="CodePanel on $sha is $status. Call get_review and act on payload.nextAction." ;;
esac
jq -n --arg r "$why" '{decision: "block", reason: $r}'
```

Register it, then run `chmod +x` on the script. It needs `gh` and `jq`.

```json title=".claude/settings.json"
{
  "hooks": {
    "Stop": [{ "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codepanel-gate.sh" }] }]
  }
}
```

## What it does in each case

Each row was run against CodePanel's own pull requests in October 2026.
Three rows used a stub `gh`. The `failure` row ran on a real failing commit with `gh pr view` stubbed to answer OPEN, since its branch had another name here. The API-failure row stubbed `gh api` to return a 502. The `pending` row's message was added after the run on a real pending commit, and checked with `gh` stubbed to return a pending status:

| Case | Result |
| --- | --- |
| Claude is already continuing because of this hook (`stop_hook_active` is true) | Exits 0: the turn ends |
| The branch has no open pull request | Exits 0 |
| The local branch has a different name from the pull request's head branch, even one `gh pr checkout -b` set to track it | Exits 0: `gh pr view` finds no pull request |
| GitHub cannot be read (the API call fails) | Exits 0 |
| The commit was not pushed, or CodePanel has posted no status on it. That is every commit on a repository whose gate is off or that CodePanel does not review, so install the hook only where the gate is on | Blocks: "CodePanel has not judged `<sha>`. Push it if needed, then call wait_for_review with headSha `<sha>` and timeoutSeconds 45." |
| The status is `pending` | Blocks with the status and its description, then "Call wait_for_review with headSha `<sha>` and timeoutSeconds 45, then act on payload.nextAction." `get_review` alone could still return the previous commit's review |
| The status is `failure` | Blocks with the status and its description, then "Call get_review and act on payload.nextAction." |
| The status is `success` | Exits 0 |

## Why it blocks only once

The hook steps aside when `stop_hook_active` is true, so a second stop in a row always ends the turn. The block is a prompt to wait for the review, not a guarantee that it arrives. If CodePanel is down, or a spending limit skipped the commit, a hook that kept blocking would hold Claude in a loop it cannot finish. A failed API call ends the turn for the same reason: Claude should not act on a status nobody read.

The status is read from GitHub rather than from CodePanel's API because it is the same check the branch ruleset requires ([Merge gate](/docs/merge-gate)). When it lets Claude stop on `success`, the `CodePanel` check the ruleset requires has passed too. The combined status lists the latest status for each context, 30 contexts per page by default, so `per_page=100` keeps `CodePanel` in view on repositories with many CI contexts.

## Test it before you rely on it

Run the script by hand on a branch with an open pull request, with the input Claude Code sends:

```bash
echo '{"stop_hook_active": false}' | .claude/hooks/codepanel-gate.sh
```

It prints nothing when the gate has passed, and a `{"decision": "block", ...}` object otherwise. With `{"stop_hook_active": true}` it must print nothing. The rest of the loop is in [Code review with Claude Code](/agents/claude-code).
