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…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.