Hooks

Hooks run scripts at fixed points in an agent session, outside the model's discretion. This page sets up each one against Precedent, using Claude Code; any harness with lifecycle scripts follows the same pattern.

One-time setup: a token for scripts

Scripts call the same MCP endpoint your agent uses, with the same OAuth grant, so everything they read is scoped to you and shows up in the audit log. Register a client, approve it once in the browser, and store the tokens:

BASE=https://your-deployment          # e.g. https://app.example.com
mkdir -p ~/.precedent && chmod 700 ~/.precedent

VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=')
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')

CLIENT_ID=$(curl -s "$BASE/api/oauth/register" -H 'Content-Type: application/json' \
  -d '{"client_name":"precedent-hooks","redirect_uris":["http://localhost:8976/cb"],"token_endpoint_auth_method":"none"}' \
  | jq -r .client_id)

open "$BASE/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=http://localhost:8976/cb&response_type=code&code_challenge=$CHALLENGE&code_challenge_method=S256&scope=decisions:read&resource=$BASE/mcp"

Approve the consent screen. The browser then lands on a localhost page that does not load; that is expected. Copy the code value out of the address bar and exchange it:

curl -s "$BASE/api/oauth/token" \
  -d grant_type=authorization_code -d code=PASTE_THE_CODE \
  -d client_id="$CLIENT_ID" -d redirect_uri=http://localhost:8976/cb -d code_verifier="$VERIFIER" \
  | jq --arg c "$CLIENT_ID" '{client_id: $c} + .' > ~/.precedent/token.json
chmod 600 ~/.precedent/token.json
Access tokens last an hour; the refresh token lasts 30 days and rotates on every use. The helper below persists the rotated token each run, so the file stays valid. Reusing an old refresh token revokes the whole grant on purpose, since reuse means it leaked. Revoke any time under Settings, Agents.

The helper every hook shares

Save this as ~/.precedent/tool.sh and chmod +x it. It refreshes the access token, calls one MCP tool, and prints the JSON result:

#!/usr/bin/env bash
# Usage: tool.sh <tool_name> '<json_arguments>'
# e.g.:  tool.sh search_decisions '{"query":"search engine"}'
set -euo pipefail
BASE="${PRECEDENT_URL:?set PRECEDENT_URL to your deployment}"
STORE="$HOME/.precedent/token.json"

CLIENT_ID=$(jq -r .client_id "$STORE")
REFRESH=$(jq -r .refresh_token "$STORE")
FRESH=$(curl -s "$BASE/api/oauth/token" -d grant_type=refresh_token \
  -d refresh_token="$REFRESH" -d client_id="$CLIENT_ID")
echo "$FRESH" | jq --arg c "$CLIENT_ID" '{client_id: $c} + .' > "$STORE"  # rotation: always persist
ACCESS=$(echo "$FRESH" | jq -r .access_token)

BODY=$(jq -n --arg name "$1" --argjson args "${2:-{}}" \
  '{jsonrpc:"2.0", id:1, method:"tools/call", params:{name:$name, arguments:$args}}')
curl -s "$BASE/mcp" \
  -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" -H "MCP-Protocol-Version: 2025-06-18" \
  -d "$BODY" | jq '.result.structuredContent'

SessionStart: open with what needs you

Runs when a session begins; whatever the script prints is added to the agent’s context. Use it to surface reviews that are due, so the agent mentions them before work starts:

// .claude/settings.json
{
  "hooks": {
    "SessionStart": [{ "hooks": [{ "type": "command",
      "command": "~/.precedent/tool.sh list_reviews_due '{}' | jq -r '.items[]? | \"Review due: \(.ref) \(.title)\"'" }] }]
  }
}

UserPromptSubmit: put the record in front of the model

Runs on every prompt; stdout is added to context before the model sees the message. The script reads the prompt from stdin and injects matching decisions. Hybrid search does the semantic matching, so the wording does not need to line up:

#!/usr/bin/env bash
# ~/.precedent/on-prompt.sh
set -euo pipefail
PROMPT=$(jq -r .prompt)
ARGS=$(jq -n --arg q "$PROMPT" '{query: $q, limit: 5}')
HITS=$(~/.precedent/tool.sh search_decisions "$ARGS" \
  | jq -r '.decisions[]? | "\(.ref) \(.title) [\(.status)]"')
[ -n "$HITS" ] && printf 'Decided in this workspace, cite before contradicting:\n%s\n' "$HITS"
exit 0
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "~/.precedent/on-prompt.sh" }] }]

This guarantees the relevant decisions are in context on every turn. It does not force the model to honour them; the next two hooks do that.

PreToolUse: gate the push

Runs before a tool call; exit code 2 blocks the call and feeds stderr back to the model. Matched to Bash and filtered to git push, it stops anything leaving the machine while a decided topic is uncited:

#!/usr/bin/env bash
# ~/.precedent/on-push.sh
set -euo pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
case "$CMD" in *"git push"*) ;; *) exit 0 ;; esac

SUMMARY=$(git log origin/HEAD..HEAD --format=%s 2>/dev/null | head -5; git diff origin/HEAD...HEAD --stat 2>/dev/null | tail -10)
[ -z "$SUMMARY" ] && exit 0
ARGS=$(jq -n --arg q "$SUMMARY" '{query: $q, limit: 3}')
HITS=$(~/.precedent/tool.sh search_decisions "$ARGS" \
  | jq -r '.decisions[]? | select(.status == "decided") | "\(.ref) \(.title)"')
if [ -n "$HITS" ]; then
  echo "This push touches decided topics. Read them and cite or supersede before pushing:" >&2
  echo "$HITS" >&2
  exit 2
fi
"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "~/.precedent/on-push.sh" }] }]

Stop: the final check

Runs when the agent believes it is finished; exit 2 sends it back to work with your reason. Same script shape as the push gate, run against the whole working tree, so nothing ends while the record is uncited:

"Stop": [{ "hooks": [{ "type": "command", "command": "~/.precedent/on-stop.sh" }] }]
Guard against loops: the Stop hook input carries stop_hook_active: true when the agent is already continuing because of a Stop hook. Exit 0 in that case, or the session cannot end.

Which hooks to start with

  1. Start with UserPromptSubmit alone. It is cheap, silent when nothing matches, and fixes the common case of an agent that simply never looked.
  2. Add the push gate when agents ship code unattended. It is the last moment a conflict is free to fix.
  3. Add Stop for long autonomous runs. For everything the hooks cannot see, put the same check in CI, which no machine skips.