Skip to content
Back to skills

Hooks Development

ASecurity

Claude Code hooks development guide. TRIGGERS - create hook, PostToolUse, PreToolUse, Stop hook, hook lifecycle, decision block.

  • 75 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added February 7, 2026
toolstypescriptshellbashdebugginggitapidevopsdocumentation

Works with

  • claude code
  • api

Security analysis

A100/100

Pro scans all 6 files and shows the line behind each finding

Scanned September 24, 2026

npx -y skills add terrylica/cc-skills --skill hooks-development --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hooks Development?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Hooks Development
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/terrylica-hooks-development/badge)](https://www.skillsdirectory.com/skills/terrylica-hooks-development)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: hooks-development
description: Claude Code hooks development guide. TRIGGERS - create hook, PostToolUse, PreToolUse, Stop hook, hook lifecycle, decision block.
allowed-tools: Read, Bash, Write, Edit, Glob, Grep
---

# Hooks Development

Guide for developing Claude Code hooks with proper output visibility patterns.

> **Self-Evolving Skill**: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.

## When to Use This Skill

- Creating a new PostToolUse or PreToolUse hook
- Hook output is not visible to Claude (most common issue)
- User asks about `decision: block` pattern
- Debugging why hook messages don't appear
- User mentions "Claude Code hooks" or "hook visibility"

---

## Quick Reference: Visibility Patterns

**Critical insight**: PostToolUse hook stdout is only visible to Claude when JSON contains `"decision": "block"`.

| Output Format                  | Claude Visibility |
| ------------------------------ | ----------------- |
| Plain text                     | Not visible       |
| JSON without `decision: block` | Not visible       |
| JSON with `decision: block`    | Visible           |

**Exit code behavior**:

| Exit Code | stdout Behavior                         | Claude Visibility             |
| --------- | --------------------------------------- | ----------------------------- |
| **0**     | JSON parsed, shown in verbose mode only | Only if `"decision": "block"` |
| **2**     | Ignored, uses stderr instead            | stderr shown to Claude        |
| **Other** | stderr shown in verbose mode            | Not shown to Claude           |

---

## Never rewrite AskUserQuestion

A PreToolUse hook must never return `updatedInput` for `AskUserQuestion` — not directly, and not through `allowWithInput`. For that tool `updatedInput` is the channel the dialog uses to deliver the user's `answers`, so a hook that returns it is taken as having answered: the menu never renders and the tool returns "The user did not answer the questions." Measured 2026-09-24 across every session transcript: 86 of 86 rewritten calls went unseen and unanswered, against 465 of 466 plain-`allow` calls that rendered and were answered.

- A plain `allow` is safe; the dialog still shows.
- To change what the user sees, `deny` with a reason that names exactly what the agent should re-ask, and make sure the re-ask passes unchanged so the loop settles in one round (`pretooluse-pr-premise-annotator.ts` is the worked example).
- Enforced three ways: `allowWithInput` refuses the tool at runtime, `TOOL_SCHEMAS` has no entry for it, and `scripts/validate-plugins.mjs` fails the gate when a hook whose matcher covers `AskUserQuestion` emits `updatedInput` or calls `allowWithInput`.

---

## Minimal Working Pattern

```bash
/usr/bin/env bash << 'SKILL_SCRIPT_EOF'
#!/usr/bin/env bash
set -euo pipefail

# Read hook payload from stdin
PAYLOAD=$(cat)
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')

[[ -z "$FILE_PATH" ]] && exit 0

# Your condition here
if [[ condition_met ]]; then
    jq -n \
        --arg reason "[HOOK] Your message to Claude" \
        '{decision: "block", reason: $reason}'
fi

exit 0
SKILL_SCRIPT_EOF
```

**Key points**:

1. Use `jq -n` to generate valid JSON
2. Include `"decision": "block"` for visibility
3. Exit with code 0
4. The "blocking error" label is cosmetic - operation continues

---

## Language choice: shell vs Bun/TypeScript

**Default to shell (bash) for hooks.** A hook fires on every matching event and
~95% bail out immediately, so **process-startup latency dominates** — not the
logic. Measured on an m3max: a full bash hook (keyword match + bail) ≈ **7 ms**;
a _do-nothing_ Bun script ≈ **17 ms**. bash also needs no runtime dependency and
matches existing hook convention. Keep the hot-path bail-out a bash builtin
`case` (no `jq`/process spawn) — see the pre-jq-fastpath in the devops-tools SCS
hooks.

**Reach for Bun/TypeScript only when a hook does real work** that amortizes the
~10 ms startup tax: parsing/transforming structured data, calling an API, sharing
typed modules across many hooks, or maintaining state. For simple
match-and-emit reminders, shell wins on speed, durability, and convention. (This
is the language doctrine's "existing convention + SOTA-native ecosystem override
the Bun-first default" clause in action.)

---

## TodoWrite Templates

### Creating a PostToolUse Hook

```markdown
1. [pending] Create hook script with shebang and set -euo pipefail
2. [pending] Parse PAYLOAD from stdin with jq
3. [pending] Add condition check for when to trigger
4. [pending] Output JSON with decision:block pattern
5. [pending] Register hook in hooks.json with matcher
6. [pending] Test by editing a matching file
7. [pending] Verify Claude sees the message in system-reminder
```

### Debugging Invisible Hook Output

```markdown
1. [pending] Verify hook executes (add debug log to /tmp)
2. [pending] Check JSON format is valid (pipe to jq .)
3. [pending] Confirm decision:block is present in output
4. [pending] Verify exit code is 0
5. [pending] Check hooks.json matcher pattern
6. [pending] Restart Claude Code session
```

---

## Reference Documentation

- [Lifecycle Reference](./references/lifecycle-reference.md) - All 10 hook events, diagrams, use cases, configuration pitfalls
- [Visibility Patterns](./references/visibility-patterns.md) - Full exit code and JSON schema details
- [Hook Templates](./references/hook-templates.md) - Copy-paste templates for common patterns
- [Debugging Guide](./references/debugging-guide.md) - Troubleshooting invisible output

---

## Post-Change Checklist (Self-Evolution)

When this skill is updated:

- [ ] Update [evolution-log.md](./references/evolution-log.md) with discovery
- [ ] Verify code examples still work
- [ ] Check if ADR needs updating: [PostToolUse Hook Visibility ADR](../../../../docs/adr/2025-12-17-posttooluse-hook-visibility.md)

---

## Related Resources

- [ADR: PostToolUse Hook Visibility](../../../../docs/adr/2025-12-17-posttooluse-hook-visibility.md)
- [GitHub Issue #3983](https://github.com/anthropics/claude-code/issues/3983) - Original bug report
- [Claude Code Hooks Reference](https://code.claude.com/docs/en/hooks) - Official documentation

---

## Troubleshooting

| Issue                      | Cause                          | Solution                                            |
| -------------------------- | ------------------------------ | --------------------------------------------------- |
| Hook output not visible    | Missing decision:block in JSON | Add `"decision": "block"` to JSON output            |
| JSON parse error in hook   | Invalid JSON syntax            | Use `jq -n` to generate valid JSON                  |
| Hook not executing         | Wrong matcher pattern          | Check hooks.json matcher regex matches tool name    |
| Plain text output ignored  | Only JSON parsed               | Wrap output in JSON with decision:block             |
| Exit code 2 behavior       | stderr used instead of stdout  | Use exit 0 with JSON, or exit 2 for stderr messages |
| Session not seeing changes | Hooks cached                   | Restart Claude Code session after hook changes      |
| Verbose mode not showing   | Disabled by default            | Enable verbose mode in Claude Code settings         |
| jq command not found       | jq not installed               | `brew install jq`                                   |

## Post-Execution Reflection

After this skill completes, check before closing:

1. **Did the command succeed?** — If not, fix the instruction or error table that caused the failure.
2. **Did parameters or output change?** — If the underlying tool's interface drifted, update Usage examples and Parameters table to match.
3. **Was a workaround needed?** — If you had to improvise (different flags, extra steps), update this SKILL.md so the next invocation doesn't need the same workaround.

Only update if the issue is real and reproducible — not speculative.

Files in this skill

  • SKILL.md5.1 KB
  • references/debugging-guide.md4.9 KB
  • references/evolution-log.md1.4 KB
  • references/hook-templates.md4.2 KB
  • references/lifecycle-reference.md63.7 KB
  • references/visibility-patterns.md4.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…