references/best-practices.md
# Cross-Platform Hooks Best Practices
> AI-facing guide for writing robust, portable hook scripts across Claude Code (Node.js) and GitHub Copilot (bash/PowerShell).
## Table of Contents
1. [Set timeout values by operation type](#set-timeout-values-by-operation-type)
2. [Write idempotent hooks](#write-idempotent-hooks)
3. [Wrap stdin parsing in error handling](#wrap-stdin-parsing-in-error-handling)
4. [Write only error messages to stderr](#write-only-error-messages-to-stderr)
5. [Write only JSON to stdout](#write-only-json-to-stdout)
6. [Check binary availability before shelling out](#check-binary-availability-before-shelling-out)
7. [Choose exit codes deliberately](#choose-exit-codes-deliberately)
8. [Use async hooks for long-running operations](#use-async-hooks-for-long-running-operations)
9. [Choose prompt hooks over command hooks for semantic evaluation](#choose-prompt-hooks-over-command-hooks-for-semantic-evaluation)
10. [Test hooks locally before wiring](#test-hooks-locally-before-wiring)
11. [Anti-patterns](#anti-patterns)
---
## Set timeout values by operation type
Always set `timeout` explicitly in hook configuration. Claude Code defaults to 600 seconds for command hooks, which is far too long for most operations. GitHub Copilot defaults to 30 seconds. Neither default is appropriate as a universal value — set the timeout to the expected operation time plus a 1-second margin.
Guidance by operation type:
| Operation | Claude Code `timeout` (seconds) | GitHub Copilot `timeoutSec` (seconds) |
| :-------- | :----------------------------: | :----------------------------------: |
| Binary availability check | 3 | 5 |
| Filesystem read | 5 | 10 |
| Local process (lint, format) | 10–30 | 15–30 |
| Test suite | 60–300 | 60–120 |
| Network operation | Do not implement | Do not implement |
Network operations are inappropriate for hooks. Hooks execute synchronously in the agent loop by default — a network call with latency or failure modes stalls every tool call that triggers the hook. Implement network-dependent logic as a background process invoked via an async hook (Claude Code) or exclude it from hooks entirely.
**Claude Code — explicit timeout:**
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/lint.mjs",
"timeout": 15
}
]
}
]
}
}
```
**GitHub Copilot — explicit timeoutSec:**
```json
{
"version": 1,
"hooks": {
"postToolUse": [
{
"type": "command",
"bash": "./hooks/lint.sh",
"powershell": "./hooks/lint.ps1",
"timeoutSec": 15
}
]
}
}
```
SOURCE: hook-creator.md mandatory constraints — "Set timeout to operation time + 1s margin"; hooks-doc.md — default timeout table; github-copilot.md — `timeoutSec` field.
---
## Write idempotent hooks
Hooks fire on every occurrence of the matching event. A `PostToolUse` hook with matcher `Write` fires on every file write — including writes performed by the hook's own side effects if they trigger additional tool calls. Design hooks so running them twice produces the same result as running them once.
Patterns that achieve idempotency:
- Use `--fix` or `--write` flags on formatters and linters rather than accumulating state
- Check existence before creating files; skip creation if the target already exists
- Use atomic file operations (write to temp file, rename) so partial writes do not leave inconsistent state
- Avoid appending to log files inside hooks that fire on every tool call — use a dedicated logging hook (for example, `SessionEnd`) or timestamp-delimited entries
**Wrong — appends unconditionally on every Write:**
```javascript
// BAD: every tool write appends a duplicate entry
const fs = require('node:fs');
fs.appendFileSync('/tmp/writes.log', `${toolInput.file_path}\n`);
```
**Correct — deduplicated append using a Set:**
```javascript
// GOOD: only appends if path is new in this session
const fs = require('node:fs');
const seen = new Set();
if (!seen.has(toolInput.file_path)) {
seen.add(toolInput.file_path);
fs.appendFileSync('/tmp/writes.log', `${toolInput.file_path}\n`);
}
```
---
## Wrap stdin parsing in error handling
Hook scripts receive JSON on stdin. The JSON may be absent, malformed, or empty when a hook is invoked in an unexpected context (testing, CI, manual debugging). A crash on bad input is a non-blocking error (exit code 1) and produces noise in logs. Exit cleanly on parse failure.
**Node.js (Claude Code):**
```javascript
'use strict';
let input;
try {
input = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
```
**Bash (GitHub Copilot or project hooks):**
```bash
#!/bin/bash
INPUT=$(cat 2>/dev/null)
if [ -z "$INPUT" ]; then
exit 0
fi
# Validate JSON before using jq
if ! echo "$INPUT" | jq . > /dev/null 2>&1; then
exit 0
fi
```
**Python (Claude Code project hooks):**
```python
#!/usr/bin/env python3
import json
import sys
try:
data = json.loads(sys.stdin.read())
except (json.JSONDecodeError, ValueError):
sys.exit(0)
```
---
## Write only error messages to stderr
stderr content reaches different audiences depending on exit code:
- Exit 2 — stderr text is fed back to Claude or the agent as an error message
- Other non-zero exit — stderr is shown in verbose mode (`Ctrl+O`) to the user, not to Claude
- Exit 0 — stderr is suppressed (Claude Code), or logged (GitHub Copilot with `set -x`)
Write to stderr only when you have information a human or the agent needs. Debug noise on stderr contaminates the feedback channel.
**Wrong — debug output on stderr:**
```javascript
// BAD: debug noise reaches agent context on exit 2
process.stderr.write(`DEBUG: input = ${JSON.stringify(input)}\n`);
process.stderr.write(`DEBUG: toolName = ${toolName}\n`);
process.exit(2);
```
**Correct — single actionable message on exit 2:**
```javascript
// GOOD: one clear message for the agent
process.stderr.write(`Hook blocked ${toolName}: destructive path detected\n`);
process.exit(2);
```
For child process calls, suppress stderr from the child to prevent leakage:
```javascript
// GOOD: child stderr suppressed via stdio config
const { execFileSync } = require('node:child_process');
execFileSync('git', ['status'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
```
---
## Write only JSON to stdout
Claude Code reads stdout as structured JSON on exit 0. Any non-JSON text on stdout breaks parsing and causes Claude Code to treat the hook output as invalid. GitHub Copilot similarly expects compact single-line JSON from hooks that return output.
Shell profile scripts (`.bashrc`, `.zshrc`) that print text on startup contaminate stdout. To prevent this, invoke hook scripts directly rather than through a login shell, or redirect profile output to stderr.
**Wrong — raw text on stdout:**
```bash
# BAD: echo produces non-JSON text on stdout
echo "Lint passed"
exit 0
```
**Correct — JSON on stdout:**
```bash
# GOOD: JSON object on stdout
echo '{"suppressOutput": true}'
exit 0
```
**Wrong — mixed output in Node.js:**
```javascript
// BAD: console.log raw string contaminates JSON channel
console.log('Processing complete');
console.log(JSON.stringify({ suppressOutput: true }));
```
**Correct — single JSON.stringify call:**
```javascript
// GOOD: only one output, always JSON
console.log(JSON.stringify({ suppressOutput: true }));
process.exit(0);
```
If a hook produces no structured output, exit 0 with no stdout. Claude Code interprets empty stdout on exit 0 as "allow and continue."
---
## Check binary availability before shelling out
A hook that calls an external binary silently fails if the binary is absent on the target machine. Check availability before calling, and exit cleanly when the binary is missing rather than producing a parse error.
**Node.js with execFileSync:**
```javascript
const { execFileSync } = require('node:child_process');
function binaryAvailable(name) {
try {
execFileSync('which', [name], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
});
return true;
} catch {
return false;
}
}
if (!binaryAvailable('ruff')) {
// exit 0: hook is a no-op if the tool is not installed
process.exit(0);
}
execFileSync('ruff', ['check', filePath], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 15000,
});
```
**Bash:**
```bash
#!/bin/bash
if ! command -v ruff &>/dev/null; then
exit 0
fi
ruff check "$FILE_PATH" 2>/dev/null
```
**Windows/cross-platform (GitHub Copilot):**
Provide both `bash` and `powershell` entries. Each entry checks availability for its own platform:
```json
{
"type": "command",
"bash": "command -v ruff &>/dev/null && ruff check \"$FILE\" || exit 0",
"powershell": "if (Get-Command ruff -ErrorAction SilentlyContinue) { ruff check $env:FILE }"
}
```
---
## Choose exit codes deliberately
Exit codes are the primary signaling mechanism between a hook and the host agent.
| Exit code | Meaning | When to use |
| :-------: | :------ | :---------- |
| 0 | Success — allow and continue | Hook passed, no blocking decision needed |
| 1 | Non-blocking error | Script error (unhandled exception, bad config); logged in verbose mode only |
| 2 | Blocking error | Hook intentionally prevents the triggering action; stderr shown to agent |
Exit 2 is only effective for events that can be blocked. For events that fire after an action has already completed (`PostToolUse`, `PostToolUseFailure`, `SessionEnd`), exit 2 shows stderr to Claude but cannot reverse the action.
```mermaid
flowchart TD
Start([Hook logic complete]) --> Q1{Did hook find a problem?}
Q1 -->|No| Exit0[exit 0 — allow action]
Q1 -->|Yes| Q2{Can this event be blocked?}
Q2 -->|Yes — PreToolUse, Stop, UserPromptSubmit, etc.| Q3{Use JSON or exit code?}
Q3 -->|Simple block| Exit2["exit 2\nWrite reason to stderr\nAgent receives stderr as error"]
Q3 -->|Structured decision| JSON["exit 0\nPrint JSON with decision field\nor hookSpecificOutput"]
Q2 -->|No — PostToolUse, SessionEnd, etc.| Feedback["exit 0 with JSON additionalContext\nor exit 2 to show stderr to Claude\nAction already completed"]
```
For `PreToolUse`, use JSON output rather than exit 2 when you need `permissionDecision: "deny"` with a reason shown to Claude:
```javascript
// Preferred for PreToolUse — structured denial
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'Write to /etc/ is not permitted',
},
}));
process.exit(0);
```
---
## Use async hooks for long-running operations
By default, hooks block Claude's execution until they complete. For test suites, build steps, or any operation taking more than a few seconds, set `"async": true` on command hooks. Claude continues working immediately; the hook result is delivered as context on the next conversation turn.
Async hooks (`"async": true`) are available only on `type: "command"` hooks in Claude Code. They cannot block or return permission decisions — the triggering action has already proceeded by the time the hook completes.
**When to use async:**
- Test suite runs triggered after every file write
- Build artifact generation
- Notification pipelines (Slack, webhook)
- Any operation where latency is acceptable and blocking is undesirable
**When to use synchronous (default):**
- Pre-flight validation that must block if it fails
- Security checks on tool inputs
- Formatter runs that should complete before the next tool call
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-tests.mjs",
"async": true,
"timeout": 300
}
]
}
]
}
}
```
Async hook output fields `systemMessage` and `additionalContext` are delivered to Claude on the next turn. Decision fields (`decision`, `permissionDecision`, `continue`) have no effect in async mode.
SOURCE: hooks-doc.md — "Run hooks in the background" section.
---
## Choose prompt hooks over command hooks for semantic evaluation
Use `type: "prompt"` when the evaluation requires language understanding rather than deterministic rule application. Prompt hooks send the hook input and your prompt to a Claude model (Haiku by default), which returns `{ "ok": true }` or `{ "ok": false, "reason": "..." }`.
Use `type: "command"` when evaluation is deterministic — string matching, exit code checking, file existence.
```mermaid
flowchart TD
Start([Decide hook type]) --> Q1{Evaluation requires language understanding?}
Q1 -->|Yes — semantic, contextual, or ambiguous| Prompt["type: 'prompt'\nFast model evaluates with $ARGUMENTS\nDefault timeout 30s\nNo tool access"]
Q1 -->|No — deterministic, regex, or exit-code-based| Q2{Needs file inspection or multi-step verification?}
Q2 -->|Yes| Agent["type: 'agent'\nSubagent with Read, Grep, Glob\nDefault timeout 60s\nUp to 50 turns"]
Q2 -->|No| Command["type: 'command'\nShell or Node.js script\nDefault timeout 600s\nFull process control"]
Prompt -.->|Supported events| SupportedP["PreToolUse, PostToolUse, PostToolUseFailure\nPermissionRequest, Stop, SubagentStop\nTaskCompleted, UserPromptSubmit"]
Command -.->|All events| SupportedC[All events including SessionStart, SessionEnd, PreCompact]
```
Prompt hooks are only supported on the events listed above. Events like `SessionStart`, `SessionEnd`, `PreCompact`, `Notification`, `ConfigChange`, `SubagentStart`, `TeammateIdle`, `WorktreeCreate`, and `WorktreeRemove` require `type: "command"`.
**Prompt hook example — semantic stop evaluation:**
```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop working. Context: $ARGUMENTS\n\nCheck: 1. All user-requested tasks are complete. 2. No unresolved errors remain. 3. No follow-up work is pending.\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"explanation\"} to continue.",
"timeout": 30
}
]
}
]
}
}
```
**Command hook example — deterministic stop evaluation:**
```javascript
// GOOD for deterministic checks — read a status file
'use strict';
const fs = require('node:fs');
let input;
try {
input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
const statusFile = process.env.CLAUDE_PROJECT_DIR + '/.task-status';
if (!fs.existsSync(statusFile)) {
process.exit(0);
}
const status = fs.readFileSync(statusFile, 'utf8').trim();
if (status !== 'COMPLETE') {
process.stderr.write(`Task not complete: status is ${status}\n`);
process.exit(2);
}
process.exit(0);
```
Prompt hook timeout includes model invocation latency. The 30-second default is appropriate for most evaluations. Increase it only if the prompt is complex and requires extended reasoning.
SOURCE: hooks-doc.md — "Prompt-based hooks" and "Agent-based hooks" sections.
---
## Test hooks locally before wiring
Test every hook by piping representative stdin before adding it to configuration. Verify three properties: stdout is valid JSON or empty, stderr is empty on the success path, and exit code matches the expected value.
**Node.js hook test pattern:**
```bash
# Test a PreToolUse hook with a Bash tool call (adjust extension to match your script)
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"},"session_id":"test","cwd":"/tmp"}' \
| node ./hooks/validate-bash.mjs
# Check exit code
echo "Exit: $?"
# Validate JSON output
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"},"session_id":"test","cwd":"/tmp"}' \
| node ./hooks/validate-bash.mjs | jq .
```
**Bash hook test pattern (GitHub Copilot):**
```bash
# Create test input
echo '{"timestamp":1704614400000,"cwd":"/tmp","toolName":"bash","toolArgs":"{\"command\":\"ls\"}"}' \
| ./hooks/my-hook.sh
# Check exit code
echo "Exit: $?"
# Validate JSON output
echo '{"timestamp":1704614400000,"cwd":"/tmp","toolName":"bash","toolArgs":"{\"command\":\"ls\"}"}' \
| ./hooks/my-hook.sh | jq .
```
**Verification checklist before wiring:**
- [ ] `jq .` parses stdout without error (or stdout is empty)
- [ ] stderr is empty when running on a valid success-path input
- [ ] exit code is 0 on success path, 2 on intentional block
- [ ] hook exits cleanly on empty stdin (`echo '' | node hook.cjs` exits 0)
- [ ] hook exits cleanly on invalid JSON (`echo 'not json' | node hook.cjs` exits 0)
After wiring to configuration, validate with the plugin validator (Claude Code plugins):
```bash
uvx skilllint@latest check ./path/to/plugin
```
---
## Anti-patterns
### Shell injection via execSync
**Wrong — string command passed to execSync:**
```javascript
// BAD: userInput in a string command allows injection
const { execSync } = require('node:child_process');
execSync(`git log --oneline ${userInput}`);
```
**Correct — execFileSync with array args:**
```javascript
// GOOD: args are array elements, never interpreted as shell
const { execFileSync } = require('node:child_process');
execFileSync('git', ['log', '--oneline', userInput], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
```
The `execSync` function passes the string to the shell, where unvalidated input can inject additional commands. `execFileSync` invokes the binary directly with arguments as discrete array elements, eliminating shell interpretation.
---
### stderr leak from child processes
**Wrong — child process stderr flows to hook stderr:**
```javascript
// BAD: child stderr leaks to hook stderr channel
execFileSync('binary', ['arg'], { stdio: 'inherit' });
```
**Correct — child stderr suppressed:**
```javascript
// GOOD: child stderr suppressed, only capture stdout
execFileSync('binary', ['arg'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
```
Child process stderr on `stdio: 'inherit'` passes through to the hook's stderr. On exit 2, that output reaches Claude as error context, polluting the error message with tool-internal debug output.
---
### Keeping an empty hooks configuration file
Omit `hooks/hooks.json` when the plugin has no hooks. Hook components are optional in Claude Code
and OpenAI plugins; an empty file adds no behavior and is not required for plugin validation.
SOURCE: <https://code.claude.com/docs/en/plugins.md> and
<https://developers.openai.com/plugins/build/plugins.md> (accessed 2026-09-24)
---
### Using plain `.js` extension for hook scripts
**Wrong — plain `.js` extension:**
```text
hooks/validate-bash.js
```
**Correct — explicit extension matching module syntax:**
```text
hooks/validate-bash.cjs ← CommonJS (require) — works in any project
hooks/validate-bash.mjs ← ESM (import) — works in any project
```
Plain `.js` fails when the project's `package.json` `"type"` field conflicts with the script's module syntax. Use `.mjs` (ESM, preferred for new scripts) or `.cjs` (CommonJS) — explicit extensions override `package.json` and load correctly in any project. See [hooks-nodejs-extension.md](./hooks-nodejs-extension.md) for the full rule and rationale.
---
### Ignoring platform differences in GitHub Copilot hooks
**Wrong — bash-only hook that fails on Windows:**
```json
{
"type": "command",
"bash": "./hooks/run-check.sh"
}
```
**Correct — both bash and PowerShell provided:**
```json
{
"type": "command",
"bash": "./hooks/run-check.sh",
"powershell": "./hooks/run-check.ps1",
"timeoutSec": 15
}
```
GitHub Copilot agents may run on Windows, macOS, or Linux. Providing only `bash` causes the hook to silently skip on Windows agents. Provide both `bash` and `powershell` entries for hooks that must run on all platforms.
SOURCE: github-copilot.md — Command Entry Fields section.
---
## References
- Claude Code Hooks Reference — hooks-doc.md (fetched 2026-03-01)
SOURCE: `https://code.claude.com/docs/en/hooks.md`
- GitHub Copilot Hooks Reference — [./github-copilot.md](./github-copilot.md)
SOURCE: `https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/using-hooks-with-github-copilot-agents` (accessed 2026-03-01)
- Hook Creator Agent — `plugins/plugin-creator/agents/hook-creator.md`
references/claude-code.md
# Claude Code Hooks Reference
SOURCE: <https://code.claude.com/docs/en/hooks-reference.md> (accessed 2026-03-01)
## Table of Contents
1. [Event Reference](#event-reference)
2. [Hook Locations](#hook-locations)
3. [Matcher Patterns](#matcher-patterns)
4. [Hook Handler Fields](#hook-handler-fields)
5. [Common Input Schema](#common-input-schema)
6. [Exit Codes](#exit-codes)
7. [JSON Output Schema](#json-output-schema)
8. [Event Input Schemas](#event-input-schemas)
9. [Event Decision Control](#event-decision-control)
10. [Prompt Hooks](#prompt-hooks)
11. [Agent Hooks](#agent-hooks)
12. [Async Hooks](#async-hooks)
13. [Environment Variables](#environment-variables)
14. [Security](#security)
15. [Debug](#debug)
---
## Event Reference
| Event | When it fires | Matcher support |
|:---|:---|:---|
| `Setup` | Repository setup/maintenance (`--init`, `--init-only`, `--maintenance` flags) | `init`, `maintenance` |
| `SessionStart` | Session begins or resumes | `startup`, `resume`, `clear`, `compact` |
| `UserPromptSubmit` | User submits a prompt, before Claude processes it | none |
| `UserPromptExpansion` | User-typed slash command expands into a full prompt | command name |
| `PreToolUse` | Before a tool call executes | tool name |
| `PermissionRequest` | When a permission dialog appears | tool name |
| `PermissionDenied` | When auto mode classifier denies a tool call | tool name |
| `PostToolUse` | After a tool call succeeds | tool name |
| `PostToolUseFailure` | After a tool call fails | tool name |
| `Notification` | When Claude Code sends a notification | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog` |
| `InstructionsLoaded` | When CLAUDE.md or `.claude/rules/*.md` is loaded into context | none |
| `SubagentStart` | When a subagent is spawned | agent type |
| `SubagentStop` | When a subagent finishes | agent type |
| `Stop` | When Claude finishes responding | none |
| `TeammateIdle` | When an agent team teammate is about to go idle | none |
| `TaskCompleted` | When a task is being marked as completed | none |
| `ConfigChange` | When a configuration file changes during a session | `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` |
| `Elicitation` | When an MCP server requests user input mid-task | MCP server name |
| `ElicitationResult` | After user responds to MCP elicitation, before response sent to server | MCP server name |
| `WorktreeCreate` | When a worktree is being created | none |
| `WorktreeRemove` | When a worktree is being removed | none |
| `CwdChanged` | When the working directory changes | none |
| `FileChanged` | When a watched file changes on disk | literal filename (e.g. `.envrc\|.env`) |
| `PreCompact` | Before context compaction | `manual`, `auto` |
| `PostCompact` | After context compaction completes | `manual`, `auto` |
| `SessionEnd` | When a session terminates | `clear`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` |
SOURCE: [Claude Code Hooks](https://code.claude.com/docs/en/hooks.md) (accessed 2026-04-23)
For subagents, `Stop` hooks are automatically converted to `SubagentStop`.
---
## Hook Locations
| Location | Scope | Shareable |
|:---|:---|:---|
| `~/.claude/settings.json` | All projects | No |
| `.claude/settings.json` | Single project | Yes |
| `.claude/settings.local.json` | Single project | No |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
| Plugin `hooks/hooks.json` | When plugin is enabled | Yes |
| Skill or agent frontmatter | While component is active | Yes |
Plugin hooks are auto-discovered from `hooks/hooks.json`. No `hooks` field required in `plugin.json`.
`allowManagedHooksOnly` blocks user, project, and plugin hooks. Only managed-level hooks remain active.
Claude Code captures a snapshot of hooks at startup. External edits require review in `/hooks` before taking effect.
Disable all hooks (not individual hooks): set `"disableAllHooks": true` in settings or use `/hooks` menu toggle.
---
## Matcher Patterns
The `matcher` field is a regex string. Omit, use `"*"`, or use `""` to match all occurrences.
| Event | Matcher filters |
|:---|:---|
| `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | tool name |
| `UserPromptExpansion` | command name |
| `SessionStart` | how session started: `startup`, `resume`, `clear`, `compact` |
| `SessionEnd` | why session ended: `clear`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` |
| `Notification` | notification type: `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog` |
| `SubagentStart`, `SubagentStop` | agent type: `Bash`, `Explore`, `Plan`, or custom agent names |
| `Setup` | `init` or `maintenance` |
| `PreCompact`, `PostCompact` | trigger: `manual`, `auto` |
| `ConfigChange` | source: `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` |
| `Elicitation`, `ElicitationResult` | MCP server name |
| `FileChanged` | literal filename pattern (e.g. `.envrc\|.env`); not regex |
| `UserPromptSubmit`, `Stop`, `TeammateIdle`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, `CwdChanged`, `InstructionsLoaded` | no matcher support — always fires |
SOURCE: [Claude Code Hooks](https://code.claude.com/docs/en/hooks.md) (accessed 2026-04-23)
MCP tools follow the pattern `mcp__<server>__<tool>`:
- `mcp__memory__create_entities`
- `mcp__filesystem__read_file`
- `mcp__github__search_repositories`
- `mcp__memory__.*` — all tools from the `memory` server
- `mcp__.*__write.*` — any tool containing "write" from any server
---
## Hook Handler Fields
### Common Fields (all hook types)
| Field | Required | Default | Description |
|:---|:---|:---|:---|
| `type` | yes | — | `"command"`, `"prompt"`, or `"agent"` |
| `timeout` | no | 600 (command), 30 (prompt), 60 (agent) | Seconds before canceling |
| `statusMessage` | no | — | Custom spinner message while hook runs |
| `once` | no | `false` | Skills only: run once per session then remove |
### Command Hook Fields
| Field | Required | Description |
|:---|:---|:---|
| `command` | yes | Shell command to execute |
| `async` | no | If `true`, runs in background without blocking |
### Prompt and Agent Hook Fields
| Field | Required | Description |
|:---|:---|:---|
| `prompt` | yes | Prompt text. Use `$ARGUMENTS` as placeholder for hook input JSON |
| `model` | no | Model to use. Defaults to a fast model |
All matching hooks run in parallel. Identical handlers are deduplicated. Handlers run in the current directory with Claude Code's environment.
`$CLAUDE_CODE_REMOTE` is set to `"true"` in remote web environments; not set in local CLI.
---
## Common Input Schema
All hook events receive these fields on stdin as JSON:
| Field | Description |
|:---|:---|
| `session_id` | Current session identifier |
| `transcript_path` | Path to conversation JSON |
| `cwd` | Current working directory when the hook is invoked |
| `permission_mode` | Current permission mode: `"default"`, `"plan"`, `"acceptEdits"`, `"dontAsk"`, or `"bypassPermissions"` |
| `hook_event_name` | Name of the event that fired |
---
## Exit Codes
| Exit code | Meaning |
|:---|:---|
| `0` | Success. Claude Code parses stdout for JSON output |
| `2` | Blocking error. stderr is fed back as error message. JSON in stdout is ignored |
| other | Non-blocking error. stderr shown in verbose mode (`Ctrl+O`). Execution continues |
### Exit Code 2 Behavior Per Event
| Hook event | Can block? | What happens on exit 2 |
|:---|:---|:---|
| `PreToolUse` | Yes | Blocks the tool call |
| `PermissionRequest` | Yes | Denies the permission |
| `UserPromptSubmit` | Yes | Blocks prompt processing and erases the prompt |
| `UserPromptExpansion` | Yes | Blocks the expansion |
| `Stop` | Yes | Prevents Claude from stopping, continues the conversation |
| `SubagentStop` | Yes | Prevents the subagent from stopping |
| `TeammateIdle` | Yes | Prevents the teammate from going idle |
| `TaskCompleted` | Yes | Prevents the task from being marked as completed |
| `ConfigChange` | Yes | Blocks the configuration change (except `policy_settings`) |
| `Elicitation` | Yes | Blocks the elicitation dialog |
| `ElicitationResult` | Yes | Blocks the elicitation response |
| `WorktreeCreate` | Yes | Any non-zero exit code causes worktree creation to fail |
| `PostToolUse` | No | Shows stderr to Claude (tool already ran) |
| `PostToolUseFailure` | No | Shows stderr to Claude (tool already failed) |
| `PermissionDenied` | No | Shows stderr to user only |
| `Notification` | No | Shows stderr to user only |
| `SubagentStart` | No | Shows stderr to user only |
| `SessionStart` | No | Shows stderr to user only |
| `Setup` | No | Shows stderr to user only |
| `SessionEnd` | No | Shows stderr to user only |
| `CwdChanged` | No | Failures logged to debug only |
| `FileChanged` | No | Failures logged to debug only |
| `InstructionsLoaded` | No | Failures logged to debug only |
| `PreCompact` | No | Shows stderr to user only |
| `PostCompact` | No | Logged to debug only |
| `WorktreeRemove` | No | Failures are logged in debug mode only |
| `StopFailure` | No | Output and exit code are ignored (logging and alerts only) |
SOURCE: [Claude Code Hooks](https://code.claude.com/docs/en/hooks.md) (accessed 2026-04-23)
---
## JSON Output Schema
Exit 0 and print JSON to stdout for structured control. Choose one approach: exit codes alone, or exit 0 + JSON. JSON is only processed on exit 0.
### Universal JSON Output Fields
| Field | Default | Description |
|:---|:---|:---|
| `continue` | `true` | If `false`, Claude stops processing entirely. Takes precedence over event-specific decision fields |
| `stopReason` | none | Message shown to user when `continue` is `false`. Not shown to Claude |
| `suppressOutput` | `false` | If `true`, hides stdout from verbose mode output |
| `systemMessage` | none | Warning message shown to the user |
Stop Claude entirely regardless of event:
```json
{ "continue": false, "stopReason": "Build failed, fix errors before continuing" }
```
### Decision Control by Event
| Events | Decision pattern | Key fields |
|:---|:---|:---|
| `UserPromptSubmit`, `PostToolUse`, `PostToolUseFailure`, `Stop`, `SubagentStop`, `ConfigChange` | Top-level `decision` | `decision: "block"`, `reason` |
| `TeammateIdle`, `TaskCompleted` | Exit code only | Exit 2 blocks, stderr fed back as feedback |
| `PreToolUse` | `hookSpecificOutput` | `permissionDecision` (allow/deny/ask), `permissionDecisionReason` |
| `PermissionRequest` | `hookSpecificOutput` | `decision.behavior` (allow/deny) |
| `WorktreeCreate` | stdout path | Hook prints absolute path to created worktree. Non-zero exit fails creation |
| `WorktreeRemove`, `Notification`, `SessionEnd`, `PreCompact` | None | No decision control |
Top-level decision (used by `UserPromptSubmit`, `PostToolUse`, `PostToolUseFailure`, `Stop`, `SubagentStop`, `ConfigChange`):
```json
{
"decision": "block",
"reason": "Test suite must pass before proceeding"
}
```
---
## Event Input Schemas
### SessionStart
Additional fields beyond common input:
| Field | Description |
|:---|:---|
| `source` | `"startup"`, `"resume"`, `"clear"`, or `"compact"` |
| `model` | Model identifier |
| `agent_type` | Agent name if started with `claude --agent <name>` (optional) |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "claude-sonnet-4-6"
}
```
### UserPromptSubmit
Additional fields:
| Field | Description |
|:---|:---|
| `prompt` | Text the user submitted |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "UserPromptSubmit",
"prompt": "Write a function to calculate the factorial of a number"
}
```
### PreToolUse
Additional fields:
| Field | Description |
|:---|:---|
| `tool_name` | Name of the tool being called |
| `tool_input` | Arguments passed to the tool (schema depends on tool) |
| `tool_use_id` | Unique identifier for this tool use |
Tool-specific `tool_input` schemas:
#### Bash
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `command` | string | `"npm test"` | Shell command to execute |
| `description` | string | `"Run test suite"` | Optional description |
| `timeout` | number | `120000` | Optional timeout in milliseconds |
| `run_in_background` | boolean | `false` | Whether to run in background |
#### Write
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `file_path` | string | `"/path/to/file.txt"` | Absolute path to the file to write |
| `content` | string | `"file content"` | Content to write |
#### Edit
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `file_path` | string | `"/path/to/file.txt"` | Absolute path to the file to edit |
| `old_string` | string | `"original text"` | Text to find and replace |
| `new_string` | string | `"replacement text"` | Replacement text |
| `replace_all` | boolean | `false` | Whether to replace all occurrences |
#### Read
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `file_path` | string | `"/path/to/file.txt"` | Absolute path to the file to read |
| `offset` | number | `10` | Optional line number to start reading from |
| `limit` | number | `50` | Optional number of lines to read |
#### Glob
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `pattern` | string | `"**/*.ts"` | Glob pattern to match files against |
| `path` | string | `"/path/to/dir"` | Optional directory to search in |
#### Grep
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `pattern` | string | `"TODO.*fix"` | Regular expression pattern |
| `path` | string | `"/path/to/dir"` | Optional file or directory to search in |
| `glob` | string | `"*.ts"` | Optional glob pattern to filter files |
| `output_mode` | string | `"content"` | `"content"`, `"files_with_matches"`, or `"count"` |
| `-i` | boolean | `true` | Case insensitive search |
| `multiline` | boolean | `false` | Enable multiline matching |
#### WebFetch
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `url` | string | `"https://example.com/api"` | URL to fetch content from |
| `prompt` | string | `"Extract the API endpoints"` | Prompt to run on the fetched content |
#### WebSearch
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `query` | string | `"react hooks best practices"` | Search query |
| `allowed_domains` | array | `["docs.example.com"]` | Optional: only include results from these domains |
| `blocked_domains` | array | `["spam.example.com"]` | Optional: exclude results from these domains |
#### Task
| Field | Type | Example | Description |
|:---|:---|:---|:---|
| `prompt` | string | `"Find all API endpoints"` | The task for the agent to perform |
| `description` | string | `"Find API endpoints"` | Short description |
| `subagent_type` | string | `"Explore"` | Type of specialized agent to use |
| `model` | string | `"sonnet"` | Optional model alias |
### PermissionRequest
Additional fields:
| Field | Description |
|:---|:---|
| `tool_name` | Name of the tool requiring permission |
| `tool_input` | Arguments passed to the tool |
| `permission_suggestions` | Optional array of "always allow" options the user would normally see |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "PermissionRequest",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf node_modules",
"description": "Remove node_modules directory"
},
"permission_suggestions": [
{ "type": "toolAlwaysAllow", "tool": "Bash" }
]
}
```
No `tool_use_id` field (unlike PreToolUse).
### PostToolUse
Additional fields:
| Field | Description |
|:---|:---|
| `tool_name` | Name of the tool that executed |
| `tool_input` | Arguments sent to the tool |
| `tool_response` | Result returned by the tool |
| `tool_use_id` | Unique identifier for this tool use |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.txt",
"content": "file content"
},
"tool_response": {
"filePath": "/path/to/file.txt",
"success": true
},
"tool_use_id": "toolu_01ABC123..."
}
```
### PostToolUseFailure
Additional fields:
| Field | Description |
|:---|:---|
| `tool_name` | Name of the tool that failed |
| `tool_input` | Arguments sent to the tool |
| `tool_use_id` | Unique identifier for this tool use |
| `error` | String describing what went wrong |
| `is_interrupt` | Optional boolean: whether failure was caused by user interruption |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "PostToolUseFailure",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite"
},
"tool_use_id": "toolu_01ABC123...",
"error": "Command exited with non-zero status code 1",
"is_interrupt": false
}
```
### Notification
Additional fields:
| Field | Description |
|:---|:---|
| `message` | Notification text |
| `title` | Optional title |
| `notification_type` | Type: `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog` |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "Notification",
"message": "Claude needs your permission to use Bash",
"title": "Permission needed",
"notification_type": "permission_prompt"
}
```
### SubagentStart
Additional fields:
| Field | Description |
|:---|:---|
| `agent_id` | Unique identifier for the subagent |
| `agent_type` | Agent name: `"Bash"`, `"Explore"`, `"Plan"`, or custom agent names |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "SubagentStart",
"agent_id": "agent-abc123",
"agent_type": "Explore"
}
```
### SubagentStop
Additional fields:
| Field | Description |
|:---|:---|
| `stop_hook_active` | `true` when Claude Code is already continuing as a result of a stop hook |
| `agent_id` | Unique identifier for the subagent |
| `agent_type` | Agent name (used for matcher filtering) |
| `agent_transcript_path` | Path to the subagent's transcript in `subagents/` folder |
| `last_assistant_message` | Text content of the subagent's final response |
```json
{
"session_id": "abc123",
"transcript_path": "~/.claude/projects/.../abc123.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "SubagentStop",
"stop_hook_active": false,
"agent_id": "def456",
"agent_type": "Explore",
"agent_transcript_path": "~/.claude/projects/.../abc123/subagents/agent-def456.jsonl",
"last_assistant_message": "Analysis complete. Found 3 potential issues..."
}
```
### Stop
Additional fields:
| Field | Description |
|:---|:---|
| `stop_hook_active` | `true` when Claude Code is already continuing as a result of a stop hook |
| `last_assistant_message` | Text content of Claude's final response |
```json
{
"session_id": "abc123",
"transcript_path": "~/.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "Stop",
"stop_hook_active": true,
"last_assistant_message": "I've completed the refactoring. Here's a summary..."
}
```
### TeammateIdle
Additional fields:
| Field | Description |
|:---|:---|
| `teammate_name` | Name of the teammate about to go idle |
| `team_name` | Name of the team |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "TeammateIdle",
"teammate_name": "researcher",
"team_name": "my-project"
}
```
### TaskCompleted
Additional fields:
| Field | Description |
|:---|:---|
| `task_id` | Identifier of the task being completed |
| `task_subject` | Title of the task |
| `task_description` | Detailed description. May be absent |
| `teammate_name` | Name of the teammate completing the task. May be absent |
| `team_name` | Name of the team. May be absent |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "TaskCompleted",
"task_id": "task-001",
"task_subject": "Implement user authentication",
"task_description": "Add login and signup endpoints",
"teammate_name": "implementer",
"team_name": "my-project"
}
```
### ConfigChange
Additional fields:
| Field | Description |
|:---|:---|
| `source` | Configuration type: `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` |
| `file_path` | Path to the specific file that was modified (optional) |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "ConfigChange",
"source": "project_settings",
"file_path": "/Users/.../my-project/.claude/settings.json"
}
```
`policy_settings` changes cannot be blocked — hooks still fire but blocking decisions are ignored.
### WorktreeCreate
Additional fields:
| Field | Description |
|:---|:---|
| `name` | Slug identifier for the new worktree (user-specified or auto-generated, e.g., `bold-oak-a3f2`) |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"hook_event_name": "WorktreeCreate",
"name": "feature-auth"
}
```
The hook must print the absolute path to the created worktree directory on stdout. Claude Code uses this path as the working directory. Non-zero exit causes worktree creation to fail. Only `type: "command"` hooks are supported.
### WorktreeRemove
Additional fields:
| Field | Description |
|:---|:---|
| `worktree_path` | Absolute path to the worktree being removed |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"hook_event_name": "WorktreeRemove",
"worktree_path": "/Users/.../my-project/.claude/worktrees/feature-auth"
}
```
No decision control. Failures are logged in debug mode only. Only `type: "command"` hooks are supported.
### PreCompact
Additional fields:
| Field | Description |
|:---|:---|
| `trigger` | `"manual"` or `"auto"` |
| `custom_instructions` | Content passed to `/compact` for `manual`. Empty for `auto` |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "PreCompact",
"trigger": "manual",
"custom_instructions": ""
}
```
### SessionEnd
Additional fields:
| Field | Description |
|:---|:---|
| `reason` | `"clear"`, `"logout"`, `"prompt_input_exit"`, `"bypass_permissions_disabled"`, or `"other"` |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "SessionEnd",
"reason": "other"
}
```
No decision control. Cannot block session termination.
### Setup
Additional fields:
| Field | Description |
|:---|:---|
| `trigger` | `"init"` (from `--init` or `--init-only`) or `"maintenance"` (from `--maintenance`) |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "Setup",
"trigger": "init"
}
```
Has access to `CLAUDE_ENV_FILE` for persisting environment variables. Output added to Claude's context. Exit code 2 shows stderr to user only (non-blocking).
### UserPromptExpansion
Additional fields:
| Field | Description |
|:---|:---|
| `expansion_type` | How the expansion was triggered |
| `command_name` | The slash command that was expanded |
| `command_args` | Arguments passed to the command |
| `command_source` | Source of the command definition |
| `prompt` | The original prompt before expansion |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "UserPromptExpansion",
"expansion_type": "skill",
"command_name": "my-skill",
"command_args": "",
"command_source": "plugin",
"prompt": "/my-skill"
}
```
Can block expansion (exit 2) or inject context. Fires before the expanded prompt reaches Claude.
### CwdChanged
Additional fields:
| Field | Description |
|:---|:---|
| `old_cwd` | The previous working directory |
| `new_cwd` | The new working directory |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/.../new-dir",
"permission_mode": "default",
"hook_event_name": "CwdChanged",
"old_cwd": "/Users/.../old-dir",
"new_cwd": "/Users/.../new-dir"
}
```
Has access to `CLAUDE_ENV_FILE`. Non-blocking — failures logged to debug only. Can return `watchPaths` in JSON output to update the list of files watched by `FileChanged` hooks.
### FileChanged
Additional fields:
| Field | Description |
|:---|:---|
| `file_path` | Absolute path to the file that changed |
| `event` | `"change"`, `"add"`, or `"unlink"` |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "FileChanged",
"file_path": "/Users/.../.envrc",
"event": "change"
}
```
Has access to `CLAUDE_ENV_FILE`. Non-blocking — failures logged to debug only. Matcher uses literal filenames (not regex): e.g. `.envrc|.env`. Can return `watchPaths` in JSON output to update the watch list.
### PermissionDenied
Additional fields:
| Field | Description |
|:---|:---|
| `tool_name` | Name of the tool that was denied |
| `tool_input` | Arguments that were passed to the tool |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "PermissionDenied",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /"
}
}
```
Fires only when the auto mode classifier denies a tool call — not on manual denials or rule denials. Non-blocking. Can return `{ "retry": true }` in `hookSpecificOutput` to tell Claude it may retry the denied tool call.
### InstructionsLoaded
Additional fields:
| Field | Description |
|:---|:---|
| `file_path` | Path to the CLAUDE.md or rules file that was loaded |
| `memory_type` | Memory type identifier |
| `load_reason` | `"session_start"`, `"nested_traversal"`, `"path_glob_match"`, `"include"`, or `"compact"` |
| `globs` | Optional glob patterns that triggered the load |
```json
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "InstructionsLoaded",
"file_path": "/Users/.../.claude/CLAUDE.md",
"memory_type": "project",
"load_reason": "session_start"
}
```
Non-blocking — used for observability only (audit logging, compliance tracking). Failures logged to debug only.
### Elicitation
Additional fields:
| Field | Description |
|:---|:---|
| `server_name` | Name of the MCP server requesting input |
| `message` | The elicitation message shown to the user |
| `request_id` | Unique identifier for the elicitation request |
Can return a programmatic response via `hookSpecificOutput` with `action` (`"accept"`, `"decline"`, or `"cancel"`) and `content` (form field values) to respond without showing the dialog.
### ElicitationResult
Additional fields:
| Field | Description |
|:---|:---|
| `server_name` | Name of the MCP server that requested input |
| `action` | User's action: `"accept"`, `"decline"`, or `"cancel"` |
| `content` | Form field values from the user's response |
Fires after user responds, before the response is sent to the MCP server. Can observe, modify, or block the response.
SOURCE: [Claude Code Hooks](https://code.claude.com/docs/en/hooks.md) (accessed 2026-04-23)
---
## Event Decision Control
### PreToolUse Decision Control
Returns decision inside `hookSpecificOutput`. Deprecated: top-level `decision`/`reason` fields. Deprecated values `"approve"` and `"block"` map to `"allow"` and `"deny"`.
| Field | Description |
|:---|:---|
| `permissionDecision` | `"allow"` bypasses permission system, `"deny"` prevents tool call, `"ask"` prompts user to confirm, `"defer"` pauses and exits (non-interactive mode only) |
| `permissionDecisionReason` | For `"allow"` and `"ask"`: shown to user but not Claude. For `"deny"`: shown to Claude |
| `updatedInput` | Modifies tool input parameters before execution |
| `additionalContext` | String added to Claude's context before the tool executes |
`"defer"` is only valid in non-interactive mode (`-p` flag) and only when Claude makes a single tool call (not a batch). Deferring pauses tool execution and exits the process with `stop_reason: "tool_deferred"`. The calling process receives the deferred tool use, handles it externally, then resumes the session to retry. Use to integrate Claude Code into Agent SDK apps that need a custom UI for tool approval.
SOURCE: [Claude Code Hooks](https://code.claude.com/docs/en/hooks.md) (accessed 2026-04-23)
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "My reason here",
"updatedInput": {
"field_to_modify": "new value"
},
"additionalContext": "Current environment: production. Proceed with caution."
}
}
```
### PermissionRequest Decision Control
| Field | Description |
|:---|:---|
| `behavior` | `"allow"` grants permission, `"deny"` denies it |
| `updatedInput` | For `"allow"` only: modifies tool input parameters before execution |
| `updatedPermissions` | For `"allow"` only: applies permission rule updates (equivalent to user selecting "always allow") |
| `message` | For `"deny"` only: tells Claude why permission was denied |
| `interrupt` | For `"deny"` only: if `true`, stops Claude |
```json
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": {
"command": "npm run lint"
}
}
}
}
```
### PostToolUse Decision Control
| Field | Description |
|:---|:---|
| `decision` | `"block"` prompts Claude with the `reason`. Omit to allow |
| `reason` | Shown to Claude when `decision` is `"block"` |
| `additionalContext` | Additional context for Claude |
| `updatedMCPToolOutput` | For MCP tools only: replaces tool output with provided value |
```json
{
"decision": "block",
"reason": "Explanation for decision",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Additional information for Claude"
}
}
```
### PostToolUseFailure Decision Control
| Field | Description |
|:---|:---|
| `additionalContext` | Additional context for Claude alongside the error |
```json
{
"hookSpecificOutput": {
"hookEventName": "PostToolUseFailure",
"additionalContext": "Additional information about the failure for Claude"
}
}
```
### UserPromptSubmit Decision Control
Two ways to add context on exit code 0:
- Plain text stdout: added as hook output in transcript
- JSON `additionalContext`: added more discretely
| Field | Description |
|:---|:---|
| `decision` | `"block"` prevents prompt processing and erases it from context |
| `reason` | Shown to user when `decision` is `"block"`. Not added to context |
| `additionalContext` | String added to Claude's context |
```json
{
"decision": "block",
"reason": "Explanation for decision",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "My additional context here"
}
}
```
### SessionStart Decision Control
| Field | Description |
|:---|:---|
| `additionalContext` | String added to Claude's context. Multiple hooks' values are concatenated |
```json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "My additional context here"
}
}
```
### Stop / SubagentStop Decision Control
| Field | Description |
|:---|:---|
| `decision` | `"block"` prevents Claude from stopping |
| `reason` | Required when `decision` is `"block"`. Tells Claude why it should continue |
```json
{
"decision": "block",
"reason": "Must be provided when Claude is blocked from stopping"
}
```
### Notification Decision Control
| Field | Description |
|:---|:---|
| `additionalContext` | String added to Claude's context |
Cannot block or modify notifications.
### SubagentStart Decision Control
Cannot block subagent creation.
| Field | Description |
|:---|:---|
| `additionalContext` | String added to the subagent's context |
```json
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": "Follow security guidelines for this task"
}
}
```
### ConfigChange Decision Control
| Field | Description |
|:---|:---|
| `decision` | `"block"` prevents configuration change from being applied |
| `reason` | Explanation shown to user when `decision` is `"block"` |
```json
{
"decision": "block",
"reason": "Configuration changes to project settings require admin approval"
}
```
### TeammateIdle and TaskCompleted Decision Control
Exit code 2 only — no JSON decision control. stderr is fed back to the model as feedback.
---
## Prompt Hooks
Events supporting `type: "prompt"` and `type: "agent"`:
- `PermissionRequest`
- `PostToolUse`
- `PostToolUseFailure`
- `PreToolUse`
- `Stop`
- `SubagentStop`
- `TaskCompleted`
- `UserPromptSubmit`
Events supporting only `type: "command"`:
- `ConfigChange`
- `Notification`
- `PreCompact`
- `SessionEnd`
- `SessionStart`
- `SubagentStart`
- `TeammateIdle`
- `WorktreeCreate`
- `WorktreeRemove`
### Prompt Hook Configuration
| Field | Required | Default | Description |
|:---|:---|:---|:---|
| `type` | yes | — | Must be `"prompt"` |
| `prompt` | yes | — | Prompt text. Use `$ARGUMENTS` as placeholder for hook input JSON. If `$ARGUMENTS` absent, input JSON is appended |
| `model` | no | fast model | Model to use for evaluation |
| `timeout` | no | 30 | Timeout in seconds |
```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "You are evaluating whether Claude should stop working. Context: $ARGUMENTS\n\nAnalyze the conversation and determine if:\n1. All user-requested tasks are complete\n2. Any errors need to be addressed\n3. Follow-up work is needed\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"your explanation\"} to continue working.",
"timeout": 30
}
]
}
]
}
}
```
### Prompt Hook Response Schema
```json
{
"ok": true,
"reason": "Explanation for the decision"
}
```
| Field | Description |
|:---|:---|
| `ok` | `true` allows the action, `false` prevents it |
| `reason` | Required when `ok` is `false`. Explanation shown to Claude |
---
## Agent Hooks
Agent hooks (`type: "agent"`) spawn a subagent with tool access (Read, Grep, Glob). The subagent runs up to 50 turns, then returns a structured decision. Same response schema as prompt hooks.
### Agent Hook Configuration
| Field | Required | Default | Description |
|:---|:---|:---|:---|
| `type` | yes | — | Must be `"agent"` |
| `prompt` | yes | — | Prompt describing what to verify. Use `$ARGUMENTS` as placeholder |
| `model` | no | fast model | Model to use |
| `timeout` | no | 60 | Timeout in seconds |
```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite and check the results. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}
```
---
## Async Hooks
Set `"async": true` on a `type: "command"` hook to run it in the background. Claude continues immediately. Only `type: "command"` hooks support `async`.
Set `"asyncRewake": true` instead of `"async": true` to run in the background and wake Claude when the process exits with code 2. On exit 2, stderr and stdout are delivered to Claude as a system reminder immediately, interrupting the idle state.
Async hooks cannot block or control behavior — `decision`, `permissionDecision`, `continue` fields have no effect.
After the background process exits, if stdout contains `systemMessage` or `additionalContext`, that content is delivered to Claude on the next conversation turn.
SOURCE: [Claude Code Hooks](https://code.claude.com/docs/en/hooks.md) (accessed 2026-04-23)
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "/path/to/run-tests.sh",
"async": true,
"timeout": 120
}
]
}
]
}
}
```
### Async Hook Limitations
- Only `type: "command"` hooks support `async`. Prompt-based hooks cannot run asynchronously.
- Cannot block tool calls or return decisions.
- Output is delivered on the next conversation turn. If session is idle, response waits until next user interaction.
- Each execution creates a separate background process. No deduplication across multiple firings.
---
## Environment Variables
| Variable | Available in | Description |
|:---|:---|:---|
| `CLAUDE_PROJECT_DIR` | All hooks | Project root directory. Wrap in quotes for paths with spaces |
| `CLAUDE_PLUGIN_ROOT` | Plugin hooks | Plugin root directory for bundled scripts |
| `CLAUDE_ENV_FILE` | SessionStart, Setup, CwdChanged, FileChanged | File path for persisting environment variables to subsequent Bash commands |
| `CLAUDE_CODE_REMOTE` | All hooks | Set to `"true"` in remote web environments; not set in local CLI |
SOURCE: [Claude Code Hooks](https://code.claude.com/docs/en/hooks.md) (accessed 2026-04-23)
### Persist Environment Variables (SessionStart only)
Write `export` statements to `CLAUDE_ENV_FILE`. Use append (`>>`) to preserve variables set by other hooks:
```bash
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export DEBUG_LOG=true' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi
exit 0
```
---
## Configuration Structure
Three levels of nesting:
1. Hook event key (e.g., `PreToolUse`, `Stop`)
2. Matcher group array — each element has `matcher` and `hooks`
3. Hook handler array — each element has `type` and type-specific fields
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-rm.sh"
}
]
}
]
}
}
```
### Skill/Agent Frontmatter Format
```yaml
---
name: secure-operations
description: Perform operations with security checks
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/security-check.sh"
---
```
### Plugin hooks.json Format
```json
{
"description": "Automatic code formatting",
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/format.sh",
"timeout": 30
}
]
}
]
}
}
```
---
## Security
Hooks run with the system user's full permissions.
- Validate and sanitize inputs — never trust input data blindly
- Always quote shell variables: `"$VAR"` not `$VAR`
- Block path traversal: check for `..` in file paths
- Use absolute paths; use `"$CLAUDE_PROJECT_DIR"` for project root
- Skip sensitive files: `.env`, `.git/`, keys
---
## Debug
```bash
claude --debug
```
Toggle verbose mode: `Ctrl+O`
```text
[DEBUG] Executing hooks for PostToolUse:Write
[DEBUG] Getting matching hook commands for PostToolUse with query: Write
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 1 hooks for query "Write"
[DEBUG] Found 1 hook commands to execute
[DEBUG] Executing hook command: <Your command> with timeout 600000ms
[DEBUG] Hook command completed with status 0: <Your stdout>
```
references/claude-session-log-schema-reference.md
# Claude Code Session Log Schema Reference
All claims in this file are sourced from direct inspection of actual session JSONL files
and verified against the LM Assist TypeScript source at `.claude/worktrees/lmassist/`.
No fields are inferred or assumed.
SOURCE: Record schema verified 2026-03-24 by direct inspection of session JSONL files under
`~/.claude/projects/`. Directory structure and project-key encoding corrected 2026-03-24
against LM Assist source ([`core/src/utils/path-utils.ts`](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/utils/path-utils.ts),
[`core/src/session-reader.ts`](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/session-reader.ts),
[`core/src/agent-session-store.ts`](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts),
[`core/src/agent-teams-service.ts`](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-teams-service.ts),
[`core/src/tasks-service.ts`](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/tasks-service.ts)).
Size statistics from 500-file scan across `~/.claude/projects/**/*.jsonl`.
---
## `~/.claude/` Directory Structure
```text
~/.claude/
├── projects/
│ └── <project-key>/ ← dash-encoded project path (see encoding below)
│ ├── <session-id>.jsonl ← main session transcript
│ ├── agent-<agent-id>.jsonl ← top-level subagent transcript
│ └── <session-id>/
│ └── subagents/
│ └── agent-<agent-id>.jsonl ← nested subagent transcript
├── tasks/
│ └── <task-list-id>/ ← one directory per task list (often a session-id)
│ ├── <task-id>.json ← individual task file
│ └── ...
├── plans/
│ └── <plan-name>.md ← plan markdown files
└── teams/
└── <team-name>/
└── config.json ← team configuration file
```
**Project key encoding**: Claude Code encodes the project path using dash-replacement (not
URL-encoding). All directory separators (`/`, `\`) and colons (`:`) are replaced with `-`.
```text
Linux: /home/user/repos/myproject → -home-user-repos-myproject
macOS: /Users/admin/project → -Users-admin-project
Windows: C:\home\project → C--home-project
```
SOURCE: [`core/src/utils/path-utils.ts` lines 17–19](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/utils/path-utils.ts#L17-L19):
```typescript
export function legacyEncodeProjectPath(projectPath: string): string {
return projectPath.replace(/[:\\/]/g, '-');
}
```
**Session files**: The main session transcript is `{sessionId}.jsonl` directly inside the
project-key directory. Subagent files are named `agent-{agentId}.jsonl`. Nested subagents live
in `{sessionId}/subagents/agent-{agentId}.jsonl`.
SOURCE: [`core/src/agent-session-store.ts` lines 2418–2438](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L2418-L2438); [`core/src/session-reader.ts` lines 169–191](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/session-reader.ts#L169-L191).
**Listing sessions**: The `SessionReader.listSessions()` method skips files starting with
`agent-` — these are subagent transcripts, not main sessions.
SOURCE: [`core/src/session-reader.ts` lines 190–191](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/session-reader.ts#L190-L191):
```typescript
if (!file.endsWith('.jsonl') || file.startsWith('agent-')) {
continue;
}
```
XDG alternative path (from ccusage): `~/.config/claude/projects/`
Glob pattern for all session transcripts: `~/.claude/projects/**/*.jsonl`
Each `.jsonl` file is newline-delimited JSON — one JSON object per line.
---
## Message Types
`type` is the discriminator field on every JSONL record. Full set:
```typescript
type ClaudeSessionMessageType =
| 'system' // System messages (init, turn_duration, stop_hook_summary, compact_boundary)
| 'user' // User prompts and tool results
| 'assistant' // Assistant responses
| 'result' // Execution result (final record in completed sessions)
| 'progress' // Progress updates during execution
| 'summary' // Context compaction summaries
| 'file-history-snapshot' // File state snapshots
```
SOURCE: [`core/src/agent-session-store.ts` lines 362–369](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L362-L369).
System messages additionally have a `subtype` field:
```typescript
type ClaudeSystemSubtype =
| 'init' // Session initialization (first record)
| 'turn_duration' // Duration of a turn
| 'stop_hook_summary' // Stop hook execution summary
| 'compact_boundary'; // Context compaction boundary marker
```
SOURCE: [`core/src/agent-session-store.ts` lines 373–378](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L373-L378).
---
## Top-Level Record Fields
Every record in a session JSONL file has these fields:
```json
{
"parentUuid": "string — UUID of parent message",
"isSidechain": false,
"userType": "string",
"cwd": "/absolute/working/directory",
"sessionId": "string — session identifier",
"version": "string",
"gitBranch": "string",
"agentId": "string — present in subagent logs",
"slug": "string",
"type": "user | assistant | system | result | progress | summary | file-history-snapshot",
"message": {},
"uuid": "string — this record's UUID",
"timestamp": "ISO 8601 string",
"requestId": "string — present on assistant records",
"sourceToolAssistantUUID": "string — present on user records that are tool results"
}
```
---
## `type: "system"` Records — Session Init
The first record in every session file is `type: "system"`, `subtype: "init"`. It contains
session metadata set when Claude Code starts:
```typescript
interface ClaudeSystemInit {
type: 'system';
subtype: 'init';
cwd: string;
session_id: string;
tools: string[];
mcp_servers: Array<{ name: string; status: string }>;
model: string;
permissionMode: string;
slash_commands: string[];
claude_code_version: string;
output_style: string;
agents?: unknown[];
plugins?: unknown[];
}
```
SOURCE: [`core/src/agent-session-store.ts` lines 389–403](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L389-L403).
---
## `type: "result"` Records — Execution Result
The last record in a completed session is `type: "result"`. It records the final outcome:
```typescript
interface ClaudeResultMessage {
type: 'result';
subtype: 'success' | 'error' | 'cancelled' | 'timeout';
session_id: string;
result?: string;
errors?: string[];
is_error: boolean;
duration_ms: number;
duration_api_ms: number;
num_turns: number;
total_cost_usd: number;
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
};
}
```
SOURCE: [`core/src/agent-session-store.ts` lines 430–447](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L430-L447).
The `SessionReader.listSessionsWithDetails()` method reads the last line of each file and
extracts `num_turns`, `total_cost_usd`, `duration_ms`, and `is_error` from this record.
SOURCE: [`core/src/session-reader.ts` lines 243–254](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/session-reader.ts#L243-L254).
---
## `type: "assistant"` Records
The `message` object on assistant records:
```typescript
interface ClaudeAssistantMessage {
type: 'assistant';
message: {
id: string;
model: string;
content: Array<{
type: 'text' | 'tool_use' | 'thinking';
text?: string; // present when type === 'text'
id?: string; // present when type === 'tool_use'
name?: string; // present when type === 'tool_use'
input?: unknown; // present when type === 'tool_use'
thinking?: string; // present when type === 'thinking' (extended thinking)
}>;
usage?: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
};
stop_reason?: string;
};
}
```
SOURCE: [`core/src/agent-session-store.ts` lines 406–427](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L406-L427).
The `thinking` content type is an extended thinking block. LM Assist extracts these separately
into `thinkingBlocks` in the session cache.
The raw JSON structure using the wire format (as it appears in the JSONL):
```json
{
"model": "claude-sonnet-4-6",
"id": "msg_...",
"type": "message",
"role": "assistant",
"content": [],
"stop_reason": "tool_use | end_turn",
"stop_sequence": null,
"usage": {}
}
```
### usage Object
Token costs are recorded **per assistant turn**, not per individual tool call.
```json
{
"input_tokens": 2,
"output_tokens": 1,
"cache_creation_input_tokens": 34915,
"cache_read_input_tokens": 0,
"cache_creation": {
"ephemeral_5m_input_tokens": 34915,
"ephemeral_1h_input_tokens": 0
},
"service_tier": "standard"
}
```
**Important**: `input_tokens` reflects the total context sent to the API for that turn, including
all accumulated tool results. There is no per-tool-call token count in native logs.
---
## `type: "user"` Records
The `message` object on user records:
```json
{
"role": "user",
"content": []
}
```
User records include: human messages, tool results returned to the model, and system messages.
### User Message Classification (PromptType)
Not all `type: "user"` records with text content are real human prompts. LM Assist classifies
them by examining the XML tag prefix of the text:
```typescript
type PromptType = 'user' | 'command' | 'command_output' | 'system_caveat' | 'hook_result';
function classifyUserPrompt(text: string, isMeta?: boolean): PromptType {
if (isMeta || text.trimStart().startsWith('<local-command-caveat>')) return 'system_caveat';
if (text.trimStart().startsWith('<command-name>') ||
text.trimStart().startsWith('<command-message>')) return 'command';
if (text.trimStart().startsWith('<local-command-stdout>')) return 'command_output';
if (text.trimStart().startsWith('<user-prompt-submit-hook>')) return 'hook_result';
return 'user';
}
```
SOURCE: [`core/src/session-cache.ts` lines 33–56](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/session-cache.ts#L33-L56).
A prompt is a "real" user prompt if `promptType` is `undefined`, `'user'`, or `'command'`.
SOURCE: [`core/src/session-cache.ts` lines 61–63](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/session-cache.ts#L61-L63):
```typescript
export function isRealUserPrompt(prompt: CachedUserPrompt): boolean {
return !prompt.promptType || prompt.promptType === 'user' || prompt.promptType === 'command';
}
```
---
## Content Item Types
`message.content` is a list. Each item has a `type` field.
### `text`
```json
{
"type": "text",
"text": "string content"
}
```
### `thinking`
Appears in **assistant** records when extended thinking is active. Contains the model's
internal reasoning before its response.
```json
{
"type": "thinking",
"thinking": "string — the model's reasoning"
}
```
SOURCE: [`core/src/agent-session-store.ts` lines 411–416](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L411-L416).
### `tool_use`
Appears in **assistant** records. Records a tool call made by the model.
```json
{
"type": "tool_use",
"id": "toolu_01ABC...",
"name": "ToolName",
"input": {}
}
```
`name` is the tool name — e.g., `Bash`, `Read`, `mcp__plugin_dh_sam__sam_plan`.
### `tool_result`
Appears in **user** records. Contains the response returned to the model.
```json
{
"type": "tool_result",
"tool_use_id": "toolu_01ABC...",
"is_error": null,
"content": "string or list"
}
```
`content` is a string for most tools. For some MCP tools it is a list of objects.
---
## Pairing tool_use with tool_result
`tool_use.id` matches `tool_result.tool_use_id`. They appear in consecutive records:
- `tool_use` in an assistant record
- `tool_result` in the next user record
```python
# Extract all tool call / result pairs
pending = {}
for rec in records:
msg = rec.get("message", {})
content = msg.get("content", []) if isinstance(msg, dict) else []
for item in content if isinstance(content, list) else []:
if not isinstance(item, dict):
continue
if item.get("type") == "tool_use":
pending[item["id"]] = item["name"]
elif item.get("type") == "tool_result" and item.get("tool_use_id") in pending:
name = pending.pop(item["tool_use_id"])
rc = item.get("content", "")
size = len(rc) if isinstance(rc, str) else len(json.dumps(rc))
yield name, size
```
---
## Computing Response Size
```python
content = tool_result_item.get("content", "")
size_chars = len(content) if isinstance(content, str) else len(json.dumps(content))
tokens_estimated = size_chars // 4 # rough 4-chars-per-token approximation
```
---
## Observed Response Sizes
From 500-file scan, `plugin_dh` MCP tools only (20 result records found):
| Tool | Calls | Avg chars | Max chars | Avg est. tokens |
|---|---|---|---|---|
| `mcp__plugin_dh_backlog__backlog_view` | 3 | 7,403 | 18,632 | 1,851 |
| `mcp__plugin_dh_backlog__backlog_list` | 1 | 13,355 | 13,355 | 3,339 |
| `mcp__plugin_dh_backlog__backlog_groom` | 15 | 247 | 247 | 62 |
| `mcp__plugin_dh_backlog__backlog_list_merged_prs` | 1 | 70 | 70 | 18 |
Other MCP tools observed in separate files:
| Tool | Result size |
|---|---|
| `mcp__context7__query-docs` | 5,685 chars (~1,421 tokens) |
| `mcp__context7__resolve-library-id` | 933 chars (~233 tokens) |
Note: SAM tools (`mcp__plugin_dh_sam__*`) appear in 823 files but were not sampled in this
initial scan. Sizes are expected to be large based on `sam_ready` returning full Task objects
(25+ fields). See backlog #1041 for progressive disclosure improvement plan.
---
## Team Sessions: Two Inspection Views
In a team session, both views are available in the logs:
**Team lead perspective** — the orchestrator's `.jsonl` contains:
- A `tool_use` record for each teammate spawn (via the `Task` tool)
- A `tool_result` record containing what that teammate returned
**Teammate perspective** — each teammate's `.jsonl` contains:
- Their full execution: thinking, tool calls, tool results, and final output
This means you can audit what prompt each teammate received and what it returned,
as well as what the teammate actually did to produce that result — independently.
### Task Spawn Record (in team-lead session)
```json
{
"type": "tool_use",
"id": "toolu_spawn_01",
"name": "Task",
"input": {
"subagent_type": "Explore",
"description": "Find all authentication middleware",
"prompt": "Search for all middleware that validates JWT tokens..."
}
}
```
### Teammate Result Record (in team-lead session)
The `tool_result` for a teammate spawn uses a `toolUseResult` wrapper — different from
the flat `content` field used by regular tool results:
```json
{
"type": "tool_result",
"toolUseResult": {
"tool_use_id": "toolu_spawn_01",
"content": "Found 3 middleware files: src/middleware/auth.ts (line 24)..."
}
}
```
Regular tool results (Bash, Read, MCP tools) use flat `tool_use_id` and `content` fields.
Teammate results wrap these inside `toolUseResult`.
---
## Teams Configuration
Team configuration is stored in `~/.claude/teams/<team-name>/config.json` — a subdirectory
per team, not a flat JSON file.
```typescript
interface TeamMember {
name: string;
agentId: string;
agentType?: string;
role?: string;
status?: string;
}
interface TeamConfig {
name: string;
members: TeamMember[];
createdAt?: string;
}
```
SOURCE: [`core/src/agent-teams-service.ts` lines 11–23](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-teams-service.ts#L11-L23), [`lines 38–45`](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-teams-service.ts#L38-L45).
The team config path is `~/.claude/teams/<team-name>/config.json` (directory + config.json).
Verified against `AgentTeamsService.getTeam()` which reads
`path.join(this.teamsDir, teamName, 'config.json')`.
SOURCE: [`core/src/agent-teams-service.ts` line 39](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-teams-service.ts#L39).
---
## Plans — `~/.claude/plans/*.md`
When Claude enters plan mode (via the `EnterPlanMode`/`ExitPlanMode` tool calls), the plan is
written as a standalone markdown file. Files are named by plan title.
```text
~/.claude/plans/
├── implement-auth-middleware.md
├── refactor-session-cache.md
└── add-webhook-endpoints.md
```
Plans are plain markdown containing goals, steps, approach, and constraints. LM Assist renders
them with a visual plan view that links each step back to the session that created it.
---
## Tasks — `~/.claude/tasks/{list-id}/{task-id}.json`
Tasks are stored as individual JSON files, one per task, organized into directories by task
list ID (typically a session ID). Each task file contains:
```typescript
interface Task {
id: string;
subject: string;
description: string;
activeForm?: string; // active display form of the task
status: 'pending' | 'in_progress' | 'completed' | 'deleted';
blocks: string[]; // task IDs this task blocks
blockedBy: string[]; // task IDs that block this task
owner?: string; // agent or user who owns the task
metadata?: Record<string, unknown>;
}
```
SOURCE: [`core/src/tasks-service.ts` lines 17–27](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/tasks-service.ts#L17-L27).
`activeForm` and `owner` are fields on the `Task` interface, verified against
`TasksService.Task`.
`status` values: `pending`, `in_progress`, `completed`, `deleted`
`blocks` and `blockedBy` arrays create a dependency graph across tasks within a list.
LM Assist reads all task directories, aggregates across sessions and projects, and renders the
full set as a unified Kanban board showing what every Claude agent is currently working on
across the entire machine.
---
## LM Assist: Session Inspection UI
Raw JSONL files are not readable at scale — a team session with 8 agents, 400 messages, and
50 tool calls requires tooling to inspect. LM Assist is a web UI that reads all session files,
plans, tasks, and team configurations and renders:
- Per-agent execution timelines with tool calls expanded
- Team lead view: spawn calls with prompts sent to each teammate and results returned
- Teammate view: full execution trace for each specialist
- Kanban board: all tasks across all sessions and projects, with dependency graph
- Plan viewer: structured plan steps linked back to the creating session
**Data sources LM Assist reads:**
| Path | Content |
|---|---|
| `~/.claude/projects/**/*.jsonl` | Session transcripts (all agents) |
| `~/.claude/tasks/**/*.json` | Task state across all sessions |
| `~/.claude/plans/*.md` | Plan files from plan mode |
| `~/.claude/teams/*/config.json` | Team configurations (one subdirectory per team) |
SOURCE: [`core/src/agent-teams-service.ts` line 39](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-teams-service.ts#L39); [`core/src/tasks-service.ts` line 99](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/tasks-service.ts#L99);
[`core/src/session-cache.ts` line 408](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/session-cache.ts#L408).
---
## Subagent File Location
Subagent sessions follow a naming convention separate from main sessions:
```text
# Top-level subagent (no parent session context):
~/.claude/projects/<project-key>/agent-<agent-id>.jsonl
# Nested subagent (spawned within a known parent session):
~/.claude/projects/<project-key>/<parent-session-id>/subagents/agent-<agent-id>.jsonl
```
The `agent-id` is a short hash, e.g., `a9afc2c`. The parent session stores a `SubagentInvocation`
record linking `toolUseId` → `agentId`.
SOURCE: [`core/src/agent-session-store.ts` lines 2424–2438](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L2424-L2438).
---
## Session Status Values
LM Assist computes a `status` field from session file state. These values do **not** appear
in the JSONL file; they are derived by LM Assist at parse time:
```typescript
type ClaudeSessionStatus =
| 'running' // File modified < 60 seconds ago
| 'completed' // Has a result record with success=true
| 'error' // Has a result record with errors
| 'interrupted'// Session ended mid-conversation
| 'idle' // 1–10 minutes since last activity, no result
| 'stale'; // > 10 minutes since last activity, no result
```
SOURCE: [`core/src/agent-session-store.ts` lines 87–107](https://github.com/langmartai/lm-assist/blob/104c96f89c8d51ed9d94d7de927142b28ff7df7c/core/src/agent-session-store.ts#L87-L107).
---
## What Is NOT in the Logs
- Per-tool-call token costs — usage is at turn level only
- Streaming events — logs contain completed messages, not stream chunks
- Hook execution records — hooks are not logged in session JSONL
- Tool execution timing — no duration field per tool call
---
## Using This Schema in Hooks
Hooks receive tool call data via stdin **before** it is written to the session log.
The `PostToolUse` hook `tool_response` field contains the same data that becomes
`tool_result.content` in the session log.
If you need to measure response sizes in a hook:
```javascript
const input = JSON.parse(process.stdin.read() || '{}');
const toolName = input.tool_name; // same as tool_use.name in session log
const response = input.tool_response; // same content as tool_result.content
const responseJson = JSON.stringify(response);
const bytes = Buffer.byteLength(responseJson, 'utf8');
```
However, if you only need historical analysis (not real-time reaction), reading the session
logs directly is simpler and requires no hook — the data is already there.
references/common-schema.md
# Cross-Platform Hooks Common Schema
SOURCE: [Claude Code hooks reference](https://code.claude.com/docs/en/hooks-reference.md) (accessed 2026-03-01)
SOURCE: [GitHub Copilot agent hooks](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/using-hooks-with-github-copilot-agents) (accessed 2026-03-01)
## Table of Contents
1. [Concept Map](#concept-map)
2. [Common Event Categories](#common-event-categories)
3. [Common Fields Across Platforms](#common-fields-across-platforms)
4. [Exit Code Conventions](#exit-code-conventions)
5. [Context Passing Patterns](#context-passing-patterns)
6. [JSON Output Control](#json-output-control)
7. [Platform Comparison Table](#platform-comparison-table)
---
## Concept Map
Both Claude Code and GitHub Copilot implement the same conceptual model:
```text
Agent lifecycle
│
├── Event fires at a lifecycle point
│ (e.g. "before tool runs", "session starts")
│
├── Trigger: does a matcher match the event context?
│ Claude Code: regex against tool_name, agent_type, etc.
│ GitHub Copilot: no matcher — all hooks in the event array run
│
└── Command or script executes
Receives context (stdin JSON or env vars)
Returns decision via exit code and/or stdout JSON
```
A "hook" is the combination of the lifecycle event, the optional trigger condition (matcher), and the handler that runs. The handler receives context about what the agent is about to do or just did, and can allow, block, or annotate the action.
---
## Common Event Categories
The following event categories exist across both Claude Code and GitHub Copilot. Event names differ by platform — see the [Platform Comparison Table](#platform-comparison-table) for exact names.
### Session Lifecycle
Events that fire once at the start or end of an agent session.
| Category | Claude Code event | GitHub Copilot event | Purpose |
| :--- | :--- | :--- | :--- |
| Session start | `SessionStart` | `sessionStart` | Load context, configure environment |
| Session end | `SessionEnd` | `sessionEnd` | Cleanup, logging, reporting |
Session start hooks are well-suited for injecting dynamic context that cannot be expressed as static files (e.g., current open issues, recent git log). Static context belongs in configuration files — not hooks.
### Tool or Action Pre and Post
Events that fire before and after the agent performs an action.
| Category | Claude Code event | GitHub Copilot event | Purpose |
| :--- | :--- | :--- | :--- |
| Before tool use | `PreToolUse` | `preToolUse` | Validate, block, or modify actions |
| After tool use | `PostToolUse` | `postToolUse` | React to completed actions |
`PreToolUse` (and `preToolUse`) are the primary control points for policy enforcement — they can block the action before it occurs.
### User Input Submission
Events that fire when a user submits a prompt.
| Category | Claude Code event | GitHub Copilot event | Purpose |
| :--- | :--- | :--- | :--- |
| User prompt submitted | `UserPromptSubmit` | `userPromptSubmitted` | Filter, log, or block user input |
### Agent or Subagent Lifecycle
Events unique to Claude Code's multi-agent architecture. GitHub Copilot has no equivalent.
| Claude Code event | Purpose |
| :--- | :--- |
| `SubagentStart` | Fires when a subagent is spawned |
| `SubagentStop` | Fires when a subagent finishes — primary hook point for task completion tracking |
| `TeammateIdle` | Fires when an agent team teammate is about to go idle |
| `TaskCompleted` | Fires when a task is being marked complete |
### Additional Claude Code Events
Events with no GitHub Copilot equivalent:
| Event | Purpose |
| :--- | :--- |
| `PermissionRequest` | When a permission dialog appears — can auto-allow or deny |
| `PostToolUseFailure` | After a tool call fails |
| `Notification` | When Claude Code sends a notification |
| `Stop` | When Claude finishes responding |
| `ConfigChange` | When a configuration file changes mid-session |
| `WorktreeCreate` | When a worktree is being created |
| `WorktreeRemove` | When a worktree is being removed |
| `PreCompact` | Before context compaction |
### Additional GitHub Copilot Events
Events with no Claude Code equivalent:
| Event | Purpose |
| :--- | :--- |
| `errorOccurred` | When an error occurs during agent execution |
---
## Common Fields Across Platforms
### Timeout
Both platforms support per-hook timeout configuration.
| Platform | Field | Default | Notes |
| :--- | :--- | :--- | :--- |
| Claude Code | `timeout` | 600s (command), 30s (prompt), 60s (agent) | Seconds; per hook handler |
| GitHub Copilot | `timeoutSec` | 30 | Seconds; per command object |
### Working Directory
Both platforms allow hooks to specify the working directory in which commands execute.
| Platform | Field | Notes |
| :--- | :--- | :--- |
| Claude Code | Not a top-level field; handlers inherit Claude Code's current working directory. Use `$CLAUDE_PROJECT_DIR` or `${CLAUDE_PLUGIN_ROOT}` to reference paths. | Path available via `cwd` in stdin JSON |
| GitHub Copilot | `cwd` | Explicit working directory per command object |
### Environment Variables
Both platforms support passing environment variables to hook commands.
| Platform | Field | Notes |
| :--- | :--- | :--- |
| Claude Code | No `env` field in hook definition. Use `CLAUDE_ENV_FILE` in `SessionStart` hooks to persist variables for subsequent Bash commands. | `$CLAUDE_CODE_REMOTE` is set to `"true"` in remote web environments |
| GitHub Copilot | `env` | Object of key/value pairs on each command object |
---
## Exit Code Conventions
### Claude Code: Three-code system
```text
Exit 0 — Success. Claude Code parses stdout for JSON output.
Exit 2 — Blocking error. stderr is fed back to Claude as an error message.
Effect varies by event (see table below).
Other — Non-blocking error. stderr shown in verbose mode only.
Execution continues.
```
Exit code 2 produces different effects depending on the event:
| Event | Can block? | Effect of exit 2 |
| :--- | :--- | :--- |
| `PreToolUse` | Yes | Blocks the tool call |
| `PermissionRequest` | Yes | Denies the permission |
| `UserPromptSubmit` | Yes | Blocks prompt and erases it |
| `Stop` | Yes | Prevents Claude from stopping |
| `SubagentStop` | Yes | Prevents the subagent from stopping |
| `TeammateIdle` | Yes | Teammate continues working |
| `TaskCompleted` | Yes | Prevents task completion |
| `ConfigChange` | Yes | Blocks the configuration change |
| `PostToolUse` | No | Shows stderr to Claude (tool already ran) |
| `PostToolUseFailure` | No | Shows stderr to Claude |
| `Notification` | No | Shows stderr to user only |
| `SubagentStart` | No | Shows stderr to user only |
| `SessionStart` | No | Shows stderr to user only |
| `SessionEnd` | No | Shows stderr to user only |
| `PreCompact` | No | Shows stderr to user only |
| `WorktreeCreate` | Yes | Any non-zero exit fails worktree creation |
| `WorktreeRemove` | No | Failures logged in debug mode only |
### GitHub Copilot: Binary system
```text
Exit 0 — Success.
Non-zero — Failure. No documented semantic for specific non-zero codes.
```
GitHub Copilot's documentation does not assign specific meanings to individual non-zero exit codes. Scripts must be executable (`chmod +x`) and declare a shebang.
---
## Context Passing Patterns
### Claude Code: stdin JSON
Claude Code passes event context to hook commands as a JSON object on standard input.
All events receive these common fields:
```json
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}
```
Event-specific fields are added to the same object. Example for `PreToolUse`:
```json
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}
```
Read stdin JSON in a shell script:
```bash
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
```
### GitHub Copilot: Env vars and stdin (undocumented)
GitHub Copilot's official documentation shows a debugging pattern where scripts read from stdin via `cat`, suggesting JSON is also passed on stdin. The exact schema is not documented by GitHub as of 2026-03-01.
Example from GitHub Copilot debugging guide:
```bash
#!/bin/bash
set -x
INPUT=$(cat)
echo "DEBUG: Received input" >&2
echo "$INPUT" >&2
```
Test by piping sample JSON:
```bash
echo '{"timestamp":1704614400000,"cwd":"/tmp","toolName":"bash","toolArgs":"{\"command\":\"ls\"}"}' | ./my-hook.sh
```
---
## JSON Output Control
### Claude Code: Structured stdout JSON (exit 0 only)
When a Claude Code command hook exits with code 0, Claude Code reads its stdout as JSON. This provides finer-grained control than exit codes alone.
Universal fields (work across all events):
| Field | Default | Description |
| :--- | :--- | :--- |
| `continue` | `true` | If `false`, Claude stops processing entirely |
| `stopReason` | none | Message shown to user when `continue` is `false` |
| `suppressOutput` | `false` | If `true`, hides stdout from verbose mode |
| `systemMessage` | none | Warning message shown to the user |
Stop Claude entirely regardless of event type:
```json
{
"continue": false,
"stopReason": "Build failed, fix errors before continuing"
}
```
Block an action (for events that support top-level `decision`):
```json
{
"decision": "block",
"reason": "Test suite must pass before proceeding"
}
```
Use `hookSpecificOutput` for events requiring richer control (`PreToolUse`, `PermissionRequest`, `SessionStart`):
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
```
The `suppressOutput` field hides stdout from the verbose mode display (`Ctrl+O`). Use it when your hook emits diagnostic text that is useful during development but noisy in normal operation.
### GitHub Copilot: No documented JSON output protocol
GitHub Copilot's hook documentation does not describe a JSON stdout protocol analogous to Claude Code's. Output formatting notes focus on ensuring JSON output is on a single line when scripts do emit JSON — not on structured decision fields.
Compact JSON output utilities:
```bash
# Unix
jq -c . <<< "$JSON_OUTPUT"
# Windows PowerShell
$data | ConvertTo-Json -Compress
```
---
## Platform Comparison Table
| Dimension | Claude Code | GitHub Copilot |
| :--- | :--- | :--- |
| Event name casing | PascalCase (`PreToolUse`, `SessionStart`) | camelCase (`preToolUse`, `sessionStart`) |
| Config file location | `settings.json`, `.claude/settings.json`, `hooks/hooks.json` (plugins), or skill/agent frontmatter | `.github/hooks/<name>.json` on default branch |
| Version field | Not required | `"version": 1` required at top level |
| Matcher support | `matcher` field (regex) on each event group | None — all hooks in the event array always run |
| Schema nesting | Event key → array of matcher groups → array of hook objects | Event key → array of command objects |
| Command specification | Single `command` key (shell-agnostic string) | Dual keys: `bash` (Unix/macOS) and `powershell` (Windows) |
| Cross-platform commands | Not applicable (single string runs in current shell) | Provide both `bash` and `powershell` keys |
| Context to hook | stdin JSON — common fields plus event-specific fields | stdin (schema undocumented officially) |
| Exit code semantics | 0 (success), 2 (blocking error), other (non-blocking error) | 0 (success), non-zero (failure) |
| JSON stdout protocol | Documented — `continue`, `suppressOutput`, `decision`, `hookSpecificOutput` | Not documented |
| Timeout field name | `timeout` (seconds) | `timeoutSec` (seconds) |
| Working directory field | Not in hook definition; inherit from Claude Code or use env vars | `cwd` per command object |
| Environment variables | Via `CLAUDE_ENV_FILE` in `SessionStart`; no per-hook `env` field | `env` object per command object |
| Hook types | `command`, `prompt`, `agent` | `command` only |
| Subagent lifecycle events | Yes (`SubagentStart`, `SubagentStop`) | No |
| Matcher-free events | `UserPromptSubmit`, `Stop`, `TeammateIdle`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove` | All events (no matcher concept) |
| Scripting languages | Any executable (shell, Python, Node, etc.) | Any executable; dual-key for cross-platform |
references/github-copilot.md
# GitHub Copilot Agent Hooks Reference
SOURCE: [Using hooks with GitHub Copilot agents](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/using-hooks-with-github-copilot-agents) (accessed 2026-03-01)
## Table of Contents
1. [File Location](#file-location)
2. [Schema](#schema)
3. [Hook Events](#hook-events)
4. [Command Entry Fields](#command-entry-fields)
5. [Exit Codes](#exit-codes)
6. [Debugging](#debugging)
7. [Differences from Claude Code Hooks](#differences-from-claude-code-hooks)
---
## File Location
- Repository hooks: `.github/hooks/` directory on the repository's **default branch**
- GitHub Copilot CLI hooks: loaded from the current working directory
- Filename is user-chosen; must be valid JSON
The hooks configuration file must be present on the default branch to be used by the Copilot coding agent.
---
## Schema
Top-level structure requires `version: 1` and a `hooks` object with event arrays:
```json
{
"version": 1,
"hooks": {
"sessionStart": [],
"sessionEnd": [],
"userPromptSubmitted": [],
"preToolUse": [],
"postToolUse": [],
"errorOccurred": []
}
}
```
Remove any event keys you do not plan to use from the `hooks` object.
---
## Hook Events
All event names are camelCase.
| Event | When it fires |
| :------------------- | :----------------------------------------------- |
| `sessionStart` | When an agent session begins |
| `sessionEnd` | When an agent session ends |
| `userPromptSubmitted`| When a user submits a prompt |
| `preToolUse` | Before the agent uses a tool |
| `postToolUse` | After the agent uses a tool |
| `errorOccurred` | When an error occurs during agent execution |
---
## Command Entry Fields
Each entry in an event array is a command object:
```json
{
"type": "command",
"bash": "<shell command or script path>",
"powershell": "<powershell command or script path>",
"cwd": "<working directory>",
"env": {
"KEY": "VALUE"
},
"timeoutSec": 30
}
```
| Field | Required | Description |
| :----------- | :------- | :--------------------------------------------------------------- |
| `type` | Yes | Must be `"command"` |
| `bash` | No | Shell command or script path for Unix/macOS |
| `powershell` | No | PowerShell command or script path for Windows |
| `cwd` | No | Working directory for the command |
| `env` | No | Object of environment variable key/value pairs |
| `timeoutSec` | No | Timeout in seconds. Default: 30 |
At least one of `bash` or `powershell` must be provided. Both can be provided for cross-platform hooks.
Example — log session start timestamp:
```json
"sessionStart": [
{
"type": "command",
"bash": "echo \"Session started: $(date)\" >> logs/session.log",
"powershell": "Add-Content -Path logs/session.log -Value \"Session started: $(Get-Date)\"",
"cwd": ".",
"timeoutSec": 10
}
]
```
Example — call an external script with environment variable:
```json
"userPromptSubmitted": [
{
"type": "command",
"bash": "./scripts/log-prompt.sh",
"powershell": "./scripts/log-prompt.ps1",
"cwd": "scripts",
"env": {
"LOG_LEVEL": "INFO"
}
}
]
```
---
## Exit Codes
No explicit non-zero exit code semantics are documented for GitHub Copilot hooks. Scripts must be executable (`chmod +x script.sh`) and have a proper shebang (e.g., `#!/bin/bash`).
JSON output from hooks must be on a single line:
- Unix: use `jq -c` to compact and validate
- Windows: use `ConvertTo-Json -Compress` in PowerShell
---
## Debugging
Enable verbose logging inside the hook script to inspect input data:
```bash
#!/bin/bash
set -x # Enable bash debug mode
INPUT=$(cat)
echo "DEBUG: Received input" >&2
echo "$INPUT" >&2
# ... rest of script
```
Test hooks locally by piping test input:
```bash
# Create test input
echo '{"timestamp":1704614400000,"cwd":"/tmp","toolName":"bash","toolArgs":"{\"command\":\"ls\"}"}' | ./my-hook.sh
# Check exit code
echo $?
# Validate output is valid JSON
./my-hook.sh | jq .
```
---
## Differences from Claude Code Hooks
| Dimension | GitHub Copilot | Claude Code |
| :--------------------- | :------------------------------------------ | :------------------------------------------------------------- |
| Event name casing | camelCase (`preToolUse`, `sessionStart`) | PascalCase (`PreToolUse`, `Stop`, `SubagentStop`) |
| Command specification | Dual keys: `bash` and `powershell` | Single `command` key (shell-agnostic string) |
| Config file location | `.github/hooks/<name>.json` on default branch | `hooks/hooks.json` (auto-discovered) or `settings.json` |
| Version field | `"version": 1` required | No version field |
| Matcher concept | None — hooks apply to all calls of that event type | `matcher` field filters by tool name or agent type |
| Schema nesting | Event key → array of command objects | Event key → array of matcher groups → array of hook objects |
references/hooks-lifecycle.svg
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="1020" viewBox="0 0 520 1020" fill="none">
<rect width="520" height="1020" rx="8" fill="#F9F9F7"/>
<rect x="160" y="25" width="190" height="42" rx="6" fill="#B8D8BE" stroke="#8FBF97" stroke-width="1"/>
<text x="255" y="51" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#2D5A34">Session Start</text>
<line x1="255" y1="67" x2="255" y2="88" stroke="#757575" stroke-width="2"/>
<polygon points="248,84 255,96 262,84" fill="#757575"/>
<rect x="160" y="98" width="190" height="42" rx="6" fill="#EDEDEB" stroke="#D5D5D0" stroke-width="1"/>
<text x="255" y="124" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">UserPromptSubmit</text>
<line x1="255" y1="140" x2="255" y2="174" stroke="#757575" stroke-width="2"/>
<polygon points="248,170 255,182 262,170" fill="#757575"/>
<rect x="60" y="158" width="380" height="482" rx="8" fill="none" stroke="#D4A27F" stroke-width="2.5" stroke-dasharray="6 4"/>
<text x="85" y="185" font-family="system-ui, -apple-system, sans-serif" font-size="14" font-weight="800" fill="#D4A27F" letter-spacing="2">AGENTIC</text>
<text x="85" y="202" font-family="system-ui, -apple-system, sans-serif" font-size="14" font-weight="800" fill="#D4A27F" letter-spacing="2">LOOP</text>
<rect x="160" y="210" width="190" height="42" rx="6" fill="#FFF8E1" stroke="#D4A27F" stroke-width="1.5"/>
<text x="255" y="236" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">PreToolUse</text>
<line x1="255" y1="252" x2="255" y2="273" stroke="#757575" stroke-width="2"/>
<polygon points="248,269 255,281 262,269" fill="#757575"/>
<rect x="160" y="283" width="190" height="42" rx="6" fill="#FFF8E1" stroke="#D4A27F" stroke-width="1.5"/>
<text x="255" y="309" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">PermissionRequest</text>
<line x1="255" y1="325" x2="255" y2="346" stroke="#757575" stroke-width="2"/>
<polygon points="248,342 255,354 262,342" fill="#757575"/>
<rect x="160" y="356" width="190" height="42" rx="6" fill="#6AABD2" stroke="#4A8DB8" stroke-width="1"/>
<text x="255" y="382" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#FFFFFF">[tool executes]</text>
<line x1="255" y1="398" x2="255" y2="419" stroke="#757575" stroke-width="2"/>
<polygon points="248,415 255,427 262,415" fill="#757575"/>
<rect x="110" y="429" width="290" height="42" rx="6" fill="#FFF8E1" stroke="#D4A27F" stroke-width="1.5"/>
<text x="255" y="455" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">PostToolUse / PostToolUseFailure</text>
<line x1="255" y1="471" x2="255" y2="492" stroke="#757575" stroke-width="2"/>
<polygon points="248,488 255,500 262,488" fill="#757575"/>
<rect x="115" y="502" width="280" height="42" rx="6" fill="#FFF8E1" stroke="#D4A27F" stroke-width="1.5"/>
<text x="255" y="528" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">SubagentStart / SubagentStop</text>
<line x1="255" y1="544" x2="255" y2="565" stroke="#757575" stroke-width="2"/>
<polygon points="248,561 255,573 262,561" fill="#757575"/>
<rect x="160" y="575" width="190" height="42" rx="6" fill="#FFF8E1" stroke="#D4A27F" stroke-width="1.5"/>
<text x="255" y="601" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">TaskCompleted</text>
<path d="M350 596 L410 596 L410 231 L362 231" fill="none" stroke="#D4A27F" stroke-width="2"/>
<polygon points="362,224 350,231 362,238" fill="#D4A27F"/>
<line x1="255" y1="617" x2="255" y2="654" stroke="#757575" stroke-width="2"/>
<polygon points="248,650 255,662 262,650" fill="#757575"/>
<rect x="160" y="664" width="190" height="42" rx="6" fill="#F8D0D0" stroke="#E0A0A0" stroke-width="1.5"/>
<text x="255" y="690" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">Stop</text>
<line x1="255" y1="706" x2="255" y2="727" stroke="#757575" stroke-width="2"/>
<polygon points="248,723 255,735 262,723" fill="#757575"/>
<rect x="160" y="737" width="190" height="42" rx="6" fill="#EDEDEB" stroke="#D5D5D0" stroke-width="1"/>
<text x="255" y="763" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">TeammateIdle</text>
<line x1="255" y1="779" x2="255" y2="800" stroke="#757575" stroke-width="2"/>
<polygon points="248,796 255,808 262,796" fill="#757575"/>
<rect x="160" y="810" width="190" height="42" rx="6" fill="#EDEDEB" stroke="#D5D5D0" stroke-width="1"/>
<text x="255" y="836" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">PreCompact</text>
<line x1="255" y1="852" x2="255" y2="873" stroke="#757575" stroke-width="2"/>
<polygon points="248,869 255,881 262,869" fill="#757575"/>
<rect x="160" y="883" width="190" height="42" rx="6" fill="#EDEDEB" stroke="#D5D5D0" stroke-width="1"/>
<text x="255" y="909" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="13" font-weight="600" fill="#1E1E1E">SessionEnd</text>
<path d="M350 904 L475 904 L475 46 L362 46" fill="none" stroke="#333333" stroke-width="2.5" stroke-dasharray="8 5"/>
<polygon points="362,38 350,46 362,54" fill="#333333"/>
<rect x="15" y="672" width="115" height="42" rx="6" fill="#F9F9F7" stroke="#B0B0B0" stroke-width="1.5" stroke-dasharray="5 3"/>
<text x="72" y="689" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" font-weight="600" fill="#757575">Notification</text>
<text x="72" y="705" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" fill="#9E9E9E">(Async)</text>
<rect x="15" y="726" width="115" height="42" rx="6" fill="#F9F9F7" stroke="#B0B0B0" stroke-width="1.5" stroke-dasharray="5 3"/>
<text x="72" y="743" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" font-weight="600" fill="#757575">ConfigChange</text>
<text x="72" y="759" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" fill="#9E9E9E">(Async)</text>
<rect x="15" y="780" width="115" height="42" rx="6" fill="#F9F9F7" stroke="#B0B0B0" stroke-width="1.5" stroke-dasharray="5 3"/>
<text x="72" y="797" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" font-weight="600" fill="#757575">WorktreeCreate</text>
<text x="72" y="813" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" fill="#9E9E9E">(Setup)</text>
<rect x="15" y="834" width="115" height="42" rx="6" fill="#F9F9F7" stroke="#B0B0B0" stroke-width="1.5" stroke-dasharray="5 3"/>
<text x="72" y="851" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" font-weight="600" fill="#757575">WorktreeRemove</text>
<text x="72" y="867" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" fill="#9E9E9E">(Teardown)</text>
<rect x="15" y="888" width="115" height="42" rx="6" fill="#F9F9F7" stroke="#B0B0B0" stroke-width="1.5" stroke-dasharray="5 3"/>
<text x="72" y="905" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" font-weight="600" fill="#757575">InstructionsLoaded</text>
<text x="72" y="921" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="10" fill="#9E9E9E">(Async)</text>
<text x="483" y="500" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#757575" transform="rotate(-90 483 500)">resumed sessions</text>
</svg>
references/hooks-nodejs-extension.md
# Node.js Hook Authoring Guide
> This guide covers Node.js hook scripts specifically. Claude Code hooks are language-agnostic — any executable works. See [hooks-python.md](./hooks-python.md) for Python hooks.
## Table of Contents
- [File Extension — Never Use Plain .js](#file-extension--never-use-plain-js)
- [Script Header](#script-header)
- [Read stdin](#read-stdin)
- [execFileSync over execSync](#execfilesync-over-execsync)
- [Timeout Discipline](#timeout-discipline)
- [Exit Codes](#exit-codes)
- [stdout — JSON Only](#stdout--json-only)
- [stderr — Error Messages Only](#stderr--error-messages-only)
- [Complete Templates](#complete-templates)
- [Blocking — PreToolUse exit 2](#blocking--pretooluse-exit-2)
- [Permission Decision](#permission-decision)
- [Context Injection — SessionStart](#context-injection--sessionstart)
- [Task Verification — Stop and SubagentStop](#task-verification--stop-and-subagentstop)
- [Binary Availability Check](#binary-availability-check)
- [Anti-Patterns](#anti-patterns)
---
## File Extension — Never Use Plain .js
Name every Node.js hook script with an explicit extension — `.cjs` or `.mjs`. Never use plain `.js`.
**Why:** Plain `.js` inherits its module type from the nearest `package.json` `"type"` field. A `.js` file in a project with `"type": "module"` is treated as ESM; in a project without that field it is CommonJS. Using `.js` for a hook script causes a module type mismatch error if the project's `"type"` field conflicts with the script's actual syntax. Explicit extensions override `package.json` entirely and load correctly regardless of project type:
- `.mjs` — forces ESM resolution (`import` syntax). **Preferred default for new Node.js scripts written from scratch.**
- `.cjs` — forces CommonJS resolution (`require()` syntax). Use when `require()` is needed (e.g., existing hooks that already use CommonJS).
New Node.js hooks written from scratch: default to `.mjs`. Existing hooks in this repo use CommonJS (`require()`) → keep `.cjs`.
```text
hooks/validate-bash.cjs ← correct — explicit CommonJS, works in any project
hooks/validate-bash.mjs ← correct — explicit ESM, works in any project
hooks/validate-bash.js ← wrong — module type depends on package.json "type"
```
Use lowercase names with hyphens to match the event or purpose:
```text
hooks/pre-bash-validator.cjs
hooks/session-context.cjs
hooks/stop-checker.cjs
```
---
## Script Header
Every hook script starts with these two lines:
```javascript
#!/usr/bin/env node
'use strict';
```
Line 1 — shebang: tells the shell to execute this file with `node` from PATH. Required for direct invocation (e.g., `chmod +x` and `./hooks/myhook.cjs`).
Line 2 — strict mode: prevents silent variable hoisting bugs and reserved word conflicts. Place immediately after the shebang with no blank line between them.
Follow the header with a doc comment:
```javascript
#!/usr/bin/env node
'use strict';
/**
* PreToolUse hook — blocks dangerous shell patterns.
* Scope: plugin
* Fires on: PreToolUse, matcher: Bash
*
* Test:
* echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | node ./hooks/pre-bash-validator.cjs
*/
```
---
## Read stdin
Claude Code delivers hook input as JSON on stdin. Read it with `readFileSync('/dev/stdin')` inside a `try/catch`. Exit 0 on parse failure — never crash on bad input.
```javascript
let inputData;
try {
inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
```
Why `readFileSync('/dev/stdin')` and not `process.stdin`: the synchronous read is simpler and avoids async stream handling in short-lived scripts. The `try/catch` catches both read errors and JSON parse errors in one block.
Extract fields after the parse:
```javascript
const toolName = inputData.tool_name ?? '';
const toolInput = inputData.tool_input ?? {};
const hookEvent = inputData.hook_event_name ?? '';
```
---
## execFileSync over execSync
When calling external binaries, use `execFileSync` with an array of arguments. Never pass a command string to `execSync`.
```javascript
const { execFileSync } = require('node:child_process');
// Correct
execFileSync('git', ['status', '--short'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
});
// Wrong — shell injection risk
const { execSync } = require('node:child_process');
execSync(`git status ${userInput}`);
```
**Why execFileSync:**
- No shell interpolation — each argument is passed directly to the OS `exec` call
- User-supplied values in args cannot escape into shell metacharacters
- `execSync` with a string spawns a shell (`/bin/sh -c "..."`), which evaluates the string
**stdio suppression:** Always pass `{ stdio: ['ignore', 'pipe', 'ignore'] }` unless you need stdout from the child. The third element suppresses child stderr, preventing it from leaking into hook output.
---
## Timeout Discipline
Set `timeout` in every `execFileSync` call and in `hooks.json`. Never rely on defaults.
| Operation type | Timeout |
|---|---|
| Local binary check (which, git status) | 3000 ms |
| Filesystem read or stat | 5000 ms |
| Network operations | Do not implement in hooks |
```javascript
// Local binary — 3000ms
execFileSync('which', ['prettier'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
});
// Filesystem operation — 5000ms
const { statSync } = require('node:fs');
// (statSync is synchronous — no timeout parameter, but keep the threshold in mind)
```
In `hooks.json`, set `timeout` in seconds:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/validate-bash.cjs",
"timeout": 5
}
]
}
]
}
}
```
Note: `hooks.json` timeout is in seconds. `execFileSync` timeout option is in milliseconds.
---
## Exit Codes
| Exit code | Meaning | Effect |
|---|---|---|
| 0 | Success or non-blocking issue | Claude proceeds normally |
| 1 | Script error | Logged, non-blocking |
| 2 | Blocking error | Claude sees stderr message; tool call or action is blocked |
Use exit 2 to block a tool call and surface an error to Claude:
```javascript
if (isDangerous) {
process.stderr.write(`Blocked: command matches dangerous pattern\n`);
process.exit(2);
}
process.exit(0);
```
Use exit 1 only for unrecoverable script failures (missing required dependency, corrupt input after retrying). Most error conditions should exit 0 (non-blocking) or exit 2 (blocking with a message).
---
## stdout — JSON Only
Write JSON to stdout using `console.log(JSON.stringify(output))`. Never write raw text to stdout in hooks that return structured output.
```javascript
const output = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Read-only operation auto-approved',
},
suppressOutput: true,
};
console.log(JSON.stringify(output));
process.exit(0);
```
**Why:** Claude Code parses stdout as JSON when the hook produces structured output. Raw text on stdout in a JSON-producing hook causes a parse error and the hook is ignored.
For hooks that only block or allow (exit code only, no JSON decision), produce no stdout.
---
## stderr — Error Messages Only
Write to stderr only when the exit code is 2 (blocking) or when producing debug output:
```javascript
// Blocking — write reason to stderr, then exit 2
process.stderr.write(`Blocked: ${reason}\n`);
process.exit(2);
```
Claude receives stderr content when a hook exits with code 2. This message is shown in the session.
Do not write debug logging to stderr in production hooks. It will appear in Claude's context on any exit 2 and adds noise.
---
## Complete Templates
These templates use CommonJS (`require()`) syntax — save them as `.cjs`. For new scripts written from scratch, prefer `.mjs` and replace `require(...)` with `import ... from '...'`.
### Blocking — PreToolUse exit 2
Blocks a tool call and shows an error to Claude.
```javascript
#!/usr/bin/env node
'use strict';
/**
* PreToolUse hook — blocks dangerous rm patterns.
* Scope: plugin
* Fires on: PreToolUse, matcher: Bash
*
* Test:
* echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | node ./hooks/block-rm-rf.cjs
*/
let inputData;
try {
inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
const toolName = inputData.tool_name ?? '';
const toolInput = inputData.tool_input ?? {};
if (toolName !== 'Bash') {
process.exit(0);
}
const command = toolInput.command ?? '';
const DANGEROUS = /\brm\s+-[^-]*r[^-]*f|\brm\s+--recursive/i;
if (DANGEROUS.test(command)) {
process.stderr.write(`Blocked: recursive force delete detected in: ${command}\n`);
process.exit(2);
}
process.exit(0);
```
Wire in `hooks.json`:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-rm-rf.cjs",
"timeout": 5
}
]
}
]
}
}
```
### Permission Decision
Returns a JSON `permissionDecision` to auto-approve or auto-deny without the user seeing a dialog.
```javascript
#!/usr/bin/env node
'use strict';
/**
* PreToolUse hook — auto-approves reads on documentation files.
* Scope: plugin
* Fires on: PreToolUse, matcher: Read
*
* Test:
* echo '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"README.md"}}' | node ./hooks/auto-approve-docs.cjs
*/
let inputData;
try {
inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
const toolName = inputData.tool_name ?? '';
const toolInput = inputData.tool_input ?? {};
if (toolName !== 'Read') {
process.exit(0);
}
const filePath = toolInput.file_path ?? '';
const DOC_EXTENSIONS = /\.(md|mdx|txt|json|yaml|yml)$/i;
if (DOC_EXTENSIONS.test(filePath)) {
const output = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Documentation file — auto-approved for read',
},
suppressOutput: true,
};
console.log(JSON.stringify(output));
process.exit(0);
}
process.exit(0);
```
Wire in `hooks.json`:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/auto-approve-docs.cjs",
"timeout": 5
}
]
}
]
}
}
```
### Context Injection — SessionStart
Injects context into the session. stdout is added to Claude's context when the event is `SessionStart`.
```javascript
#!/usr/bin/env node
'use strict';
/**
* SessionStart hook — injects project environment context.
* Scope: plugin
* Fires on: SessionStart, matcher: startup|resume
*
* Test:
* echo '{"hook_event_name":"SessionStart","source":"startup"}' | node ./hooks/session-context.cjs
*/
let inputData;
try {
inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
const nodeVersion = process.version;
const cwd = process.cwd();
const nodeEnv = process.env.NODE_ENV ?? 'development';
const contextText = [
`Node version: ${nodeVersion}`,
`NODE_ENV: ${nodeEnv}`,
`Working directory: ${cwd}`,
].join('\n');
const output = {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: `<project-context>\n${contextText}\n</project-context>`,
},
};
console.log(JSON.stringify(output));
process.exit(0);
```
Wire in `hooks.json`:
```json
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-context.cjs",
"timeout": 5
}
]
}
]
}
}
```
### Task Verification — Stop and SubagentStop
Exit 2 forces Claude to continue working. Exit 0 allows Claude to stop.
```javascript
#!/usr/bin/env node
'use strict';
/**
* Stop hook — verifies a task marker file exists before allowing Claude to stop.
* Scope: plugin
* Fires on: Stop (no matcher)
*
* Test:
* echo '{"hook_event_name":"Stop","stop_hook_active":false}' | node ./hooks/verify-complete.cjs
*/
const { existsSync } = require('node:fs');
const { join } = require('node:path');
let inputData;
try {
inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
// Prevent infinite loop — if hook is already active, allow stop
if (inputData.stop_hook_active === true) {
process.exit(0);
}
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
const markerPath = join(projectDir, '.task-complete');
if (!existsSync(markerPath)) {
process.stderr.write(`Task not complete: ${markerPath} not found. Continue working.\n`);
process.exit(2);
}
process.exit(0);
```
Wire in `hooks.json` (Stop has no matcher):
```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/verify-complete.cjs",
"timeout": 5
}
]
}
]
}
}
```
For `SubagentStop`, change `hook_event_name` in the test and the `hookEventName` in the output (if producing JSON). The exit code behavior is identical.
### Binary Availability Check
Check whether a binary is available before running it. Use `execFileSync('which', [binary])` with a 3000 ms timeout.
```javascript
#!/usr/bin/env node
'use strict';
/**
* PostToolUse hook — runs prettier after Write/Edit if available.
* Scope: plugin
* Fires on: PostToolUse, matcher: Write|Edit
*
* Test:
* echo '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"src/index.ts"}}' | node ./hooks/format-on-write.cjs
*/
const { execFileSync } = require('node:child_process');
let inputData;
try {
inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
const toolInput = inputData.tool_input ?? {};
const filePath = toolInput.file_path ?? toolInput.new_file_path ?? '';
if (!filePath) {
process.exit(0);
}
function binaryAvailable(binary) {
try {
execFileSync('which', [binary], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
});
return true;
} catch {
return false;
}
}
if (!binaryAvailable('prettier')) {
// prettier not installed — silently skip
process.exit(0);
}
try {
execFileSync('prettier', ['--write', filePath], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
});
} catch {
// Format failure is non-blocking
process.exit(0);
}
process.exit(0);
```
Wire in `hooks.json`:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/format-on-write.cjs",
"timeout": 10
}
]
}
]
}
}
```
---
## Anti-Patterns
### Wrong extension for Node.js scripts
```text
hooks/validate-bash.js
```
```text
hooks/validate-bash.cjs
```
Plain `.js` causes module type mismatch errors. See [File Extension — Never Use Plain .js](#file-extension--never-use-plain-js) for the full rule and rationale.
---
### execSync with string command
```javascript
// Wrong
const { execSync } = require('node:child_process');
execSync(`git log --oneline ${userInput}`);
```
```javascript
// Correct
const { execFileSync } = require('node:child_process');
execFileSync('git', ['log', '--oneline', userInput], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
});
```
`execSync` with a string spawns a shell. User input in the string can inject shell metacharacters. `execFileSync` with an args array passes each argument directly to the OS — no shell expansion.
---
### stderr leak from child process
```javascript
// Wrong — child stderr goes to hook stderr
execFileSync('binary', ['arg'], { stdio: 'inherit' });
```
```javascript
// Correct — child stderr suppressed
execFileSync('binary', ['arg'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
});
```
`stdio: 'inherit'` passes child stdout and stderr through to the hook process. On exit 2, anything in stderr is shown to Claude. Suppress child stderr unless you specifically intend to surface it.
---
### Raw text to stdout in a JSON hook
```javascript
// Wrong
console.log('Auto-approved');
process.exit(0);
```
```javascript
// Correct
const output = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Auto-approved',
},
suppressOutput: true,
};
console.log(JSON.stringify(output));
process.exit(0);
```
Claude Code expects JSON on stdout when the hook returns a structured decision. Raw text causes a parse failure and the hook decision is ignored.
---
### No try/catch on stdin parse
```javascript
// Wrong — crashes on bad input
const inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
```
```javascript
// Correct — exits cleanly on bad input
let inputData;
try {
inputData = JSON.parse(require('node:fs').readFileSync('/dev/stdin', 'utf8'));
} catch {
process.exit(0);
}
```
If Claude Code sends malformed JSON or no input, the uncaught exception produces an exit 1 (error), which is logged as a script failure. Exit 0 on parse failure is the correct non-blocking behavior.
---
### Missing timeout on execFileSync
```javascript
// Wrong — hangs indefinitely if binary stalls
execFileSync('git', ['fetch']);
```
```javascript
// Correct
execFileSync('git', ['fetch'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 3000,
});
```
Hooks run synchronously in the Claude Code event loop. A stalled child process blocks the entire hook. Always set `timeout`.
---
### Deleting hooks-json when unused
```text
(no hooks/hooks.json file)
```
```json
{ "hooks": {} }
```
An absent `hooks.json` in a plugin directory signals an incomplete plugin structure. Keep the file with an empty hooks object.
---
## Sources
- [Hooks Reference](https://code.claude.com/docs/en/hooks.md) (accessed 2026-01-28)
- [Hooks Guide](https://code.claude.com/docs/en/hooks-guide.md) (accessed 2026-01-28)
- [Plugin Components Reference](https://code.claude.com/docs/en/plugins-reference.md#hooks) (accessed 2026-01-28)
- Patterns derived from `plugin-creator:hook-creator` agent mandatory constraints (2026-03-01)
- Patterns derived from `plugin-creator:hooks-patterns` skill code examples (2026-03-01)
references/hooks-python.md
# Python Hook Authoring Guide
## Table of Contents
- [When to use Python vs CJS](#when-to-use-python-vs-cjs)
- [Shebang](#shebang)
- [Read stdin](#read-stdin)
- [Exit codes](#exit-codes)
- [Write to stdout](#write-to-stdout)
- [Write to stderr](#write-to-stderr)
- [Subprocess calls](#subprocess-calls)
- [Templates](#templates)
- [Blocking hook](#blocking-hook)
- [Permission decision](#permission-decision)
- [Context injection](#context-injection)
- [Task verification](#task-verification)
- [Anti-patterns](#anti-patterns)
---
## When to use Python vs CJS
Use Python when the hook integrates with Python tooling or requires file parsing or regex.
```text
Python: integrating with ruff, mypy, pytest; parsing Python ASTs; regex matching on file content
CJS: default for plugin hooks — avoids Python env dependency, runs with Node.js only
```
Python hooks require `python3` on `PATH` and the project virtualenv active if they import third-party packages. CJS hooks have no such dependency. Choose Python only when the tooling benefit outweighs the environment constraint.
---
## Shebang
Add this as the first line of every Python hook script.
```python
#!/usr/bin/env python3
```
---
## Read stdin
Claude Code delivers hook input as a JSON object on stdin. Read it with `sys.stdin.read()` and parse with `json.loads()`. Guard the parse with `try/except` and exit 0 on failure so a malformed payload does not block Claude.
```python
#!/usr/bin/env python3
import json
import sys
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
```
Common fields present in every hook event:
```json
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}
```
Event-specific fields — for example, a `PreToolUse` Bash hook also receives:
```json
{
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}
```
---
## Exit codes
Exit code is the primary signal Claude Code reads from a hook.
| Exit code | Meaning | What Claude Code does |
|-----------|---------|----------------------|
| `0` | Success | Parses stdout for JSON output fields; action proceeds |
| `2` | Blocking error | Ignores stdout; feeds stderr text to Claude as error message; blocks the action (for blockable events) |
| Any other | Non-blocking error | Shows stderr in verbose mode; execution continues |
Exit code 2 behavior depends on the event. Blockable events include `PreToolUse`, `Stop`, `SubagentStop`, `UserPromptSubmit`, and `TeammateIdle`. Non-blockable events like `PostToolUse` and `SessionStart` show stderr to Claude or the user but cannot prevent the action.
SOURCE: [Hooks reference — Exit code output](https://code.claude.com/docs/en/hooks-reference.md) (accessed 2026-03-01)
---
## Write to stdout
Write only `json.dumps()` output to stdout when returning structured hook decisions. Plain text on stdout is shown to the user in the transcript. Structured JSON output must be the only content on stdout.
```python
import json
import sys
output = {"decision": "block", "reason": "Tests must pass before stopping"}
sys.stdout.write(json.dumps(output))
sys.exit(0)
```
Use `sys.stdout.write()` rather than `print()`. `print()` appends a newline, which is harmless for JSON parsing, but it can cause problems when your shell profile also prints text — Claude Code cannot parse stdout that contains mixed text and JSON. Using `sys.stdout.write()` is explicit intent that this is structured output.
---
## Write to stderr
Write human-readable error messages to stderr. On exit 2, Claude Code feeds stderr text back to Claude as the blocking reason. On non-blocking exit codes, stderr appears in verbose mode.
```python
import sys
sys.stderr.write("Ruff lint failed — fix errors before proceeding\n")
sys.exit(2)
```
---
## Subprocess calls
Use `subprocess.run()` with `capture_output=True` and a `timeout`. Always handle `subprocess.TimeoutExpired` so a slow tool does not hang Claude.
```python
import subprocess
import sys
try:
result = subprocess.run(["ruff", "check", "--select", "E,W", "."], capture_output=True, text=True, timeout=3)
except subprocess.TimeoutExpired:
sys.stderr.write("Lint check timed out\n")
sys.exit(1)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.exit(2)
sys.exit(0)
```
---
## Templates
### Blocking hook
Use for `PreToolUse` when you need to block a tool call based on its input. Exit 2 with a message on stderr.
```python
#!/usr/bin/env python3
"""PreToolUse hook — block dangerous shell patterns."""
import json
import sys
BLOCKED_PATTERNS = ["rm -rf /", "git push --force"]
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
command = payload.get("tool_input", {}).get("command", "")
for pattern in BLOCKED_PATTERNS:
if pattern in command:
sys.stderr.write(f"Blocked: pattern '{pattern}' is not allowed\n")
sys.exit(2)
sys.exit(0)
```
### Permission decision
Use for `PreToolUse` when you want fine-grained control — allow, deny, or ask. Return JSON via stdout with exit 0. The decision lives inside `hookSpecificOutput`.
```python
#!/usr/bin/env python3
"""PreToolUse hook — deny write operations to production config."""
import json
import sys
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
tool_input = payload.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if "production" in file_path:
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Direct edits to production config are not allowed",
}
}
sys.stdout.write(json.dumps(output))
sys.exit(0)
output = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}
sys.stdout.write(json.dumps(output))
sys.exit(0)
```
The three values for `permissionDecision`:
| Value | Effect |
|-------|--------|
| `"allow"` | Bypasses the permission system; shown to user |
| `"deny"` | Prevents the tool call; reason shown to Claude |
| `"ask"` | Prompts the user to confirm; reason shown to user |
SOURCE: [Hooks reference — PreToolUse decision control](https://code.claude.com/docs/en/hooks-reference.md) (accessed 2026-03-01)
### Context injection
Use for `SessionStart` to inject dynamic context into Claude's session. Return JSON with `additionalContext` via stdout with exit 0.
```python
#!/usr/bin/env python3
"""SessionStart hook — inject current git branch and open issues count."""
import json
import subprocess
import sys
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
context_parts = []
try:
branch = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=3)
if branch.returncode == 0:
context_parts.append(f"Current git branch: {branch.stdout.strip()}")
except subprocess.TimeoutExpired:
pass
context = "\n".join(context_parts)
output = {"hookEventName": "SessionStart", "additionalContext": context}
sys.stdout.write(json.dumps(output))
sys.exit(0)
```
For `SessionStart`, plain text on stdout is also added as visible context, but the `additionalContext` JSON field is added more discretely.
SOURCE: [Hooks reference — SessionStart decision control](https://code.claude.com/docs/en/hooks-reference.md) (accessed 2026-03-01)
### Task verification
Use for `Stop` to force Claude to continue if a quality gate fails. Exit 2 with a message on stderr to prevent Claude from stopping.
```python
#!/usr/bin/env python3
"""Stop hook — require passing tests before Claude finishes."""
import json
import subprocess
import sys
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
# Avoid infinite loop — check if stop hook already fired this turn
if payload.get("stop_hook_active"):
sys.exit(0)
try:
result = subprocess.run(["python", "-m", "pytest", "--tb=no", "-q"], capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
sys.stderr.write("Test run timed out — skipping gate\n")
sys.exit(0)
if result.returncode != 0:
sys.stderr.write("Tests are failing. Fix the failing tests before finishing.\n" + result.stdout[-500:])
sys.exit(2)
sys.exit(0)
```
Check `stop_hook_active` before applying the gate. When Claude Code continues as a result of a stop hook, it sets `stop_hook_active: true` on the next `Stop` event. Without this check, the hook creates an infinite loop.
SOURCE: [Hooks reference — Stop input](https://code.claude.com/docs/en/hooks-reference.md) (accessed 2026-03-01)
---
## Anti-patterns
### Using print() for hook output
`print()` appends a newline and activates any shell profile output. Use `sys.stdout.write(json.dumps(output))` for structured JSON responses.
```python
# Wrong
print(json.dumps(output))
# Correct
sys.stdout.write(json.dumps(output))
sys.exit(0)
```
### Not catching JSON parse errors
Unparseable stdin causes an unhandled exception, which exits with code 1 — a non-blocking error that shows in verbose mode. Protect every hook with a try/except that exits 0 on parse failure so Claude is not blocked by a malformed payload.
```python
# Wrong
payload = json.loads(sys.stdin.read()) # raises on malformed input
# Correct
try:
payload = json.loads(sys.stdin.read())
except json.JSONDecodeError:
sys.exit(0)
```
### Mixing JSON and non-JSON on stdout
Any non-JSON text on stdout causes JSON validation to fail. If your script imports a library that prints on import, or if your shell profile prints a greeting, Claude Code cannot parse the hook response.
```python
# Wrong — prints debugging text before JSON
print("debug: checking payload")
sys.stdout.write(json.dumps(output))
# Correct — use stderr for diagnostic output
sys.stderr.write("debug: checking payload\n")
sys.stdout.write(json.dumps(output))
sys.exit(0)
```
references/inline-agent-hooks.md
# Inline Agent Frontmatter — Hooks, MCP, Skills, Memory
SOURCE: [Create custom subagents](https://code.claude.com/docs/en/sub-agents.md) (accessed 2026-03-01)
## Table of Contents
1. [Supported Frontmatter Fields](#supported-frontmatter-fields)
2. [Define Hooks in Agent Frontmatter](#define-hooks-in-agent-frontmatter)
3. [Stop Hook Auto-Conversion to SubagentStop](#stop-hook-auto-conversion-to-subagentstop)
4. [SubagentStart and SubagentStop in settings-json](#subagentstart-and-subagentstop-in-settings-json)
5. [mcpServers Field](#mcpservers-field)
6. [skills Field](#skills-field)
7. [memory Field](#memory-field)
8. [background Field](#background-field)
9. [isolation: worktree Field](#isolation-worktree-field)
---
## Supported Frontmatter Fields
Project, user, and managed agents require `name` and `description`. Plugin agents have filename
and generic-description fallbacks, but should provide both fields for reliable routing. All other
fields are optional.
| Field | Required | Description |
| :---------------- | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Unique identifier using lowercase letters and hyphens |
| `description` | Yes | When Claude should delegate to this subagent |
| `tools` | No | Tools the subagent can use. Inherits all tools if omitted |
| `disallowedTools` | No | Tools to deny, removed from inherited or specified list |
| `model` | No | Model to use: `sonnet`, `opus`, `haiku`, or `inherit`. Defaults to `inherit` |
| `permissionMode` | No | Permission mode: `default`, `acceptEdits`, `dontAsk`, `bypassPermissions`, or `plan` |
| `maxTurns` | No | Maximum number of agentic turns before the subagent stops |
| `skills` | No | Skills to load into the subagent's context at startup. Full skill content is injected, not just made available for invocation. Subagents do not inherit skills from the parent conversation |
| `mcpServers` | No | MCP servers available to this subagent. Each entry is either a server name referencing an already-configured server (e.g., `"slack"`) or an inline definition with the server name as key and a full MCP server config as value |
| `hooks` | No | Lifecycle hooks scoped to this subagent |
| `memory` | No | Persistent memory scope: `user`, `project`, or `local`. Enables cross-session learning |
| `background` | No | Set to `true` to always run this subagent as a background task. Default: `false` |
| `isolation` | No | Set to `worktree` to run the subagent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the subagent makes no changes |
---
## Define Hooks in Agent Frontmatter
Hooks defined in agent frontmatter run only while that specific subagent is active and are cleaned up when it finishes.
All hook events are supported. Common events for subagents:
| Event | Matcher input | When it fires |
| :------------ | :------------ | :------------------------------------------------------------------ |
| `PreToolUse` | Tool name | Before the subagent uses a tool |
| `PostToolUse` | Tool name | After the subagent uses a tool |
| `Stop` | (none) | When the subagent finishes (converted to `SubagentStop` at runtime) |
Example — validate Bash commands before execution, run linter after file edits:
```yaml
---
name: code-reviewer
description: Review code changes with automatic linting
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/validate-command.sh $TOOL_INPUT"
PostToolUse:
- matcher: "Edit|Write"
hooks:
- type: command
command: "./scripts/run-linter.sh"
---
```
Example — allow only read-only database queries via `PreToolUse` validation:
```yaml
---
name: db-reader
description: Execute read-only database queries
tools: Bash
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/validate-readonly-query.sh"
---
```
Claude Code passes hook input as JSON via stdin to hook commands. Exit code `2` blocks the operation and returns stderr to Claude.
---
## Stop Hook Auto-Conversion to SubagentStop
`Stop` hooks defined in agent frontmatter are automatically converted to `SubagentStop` events at runtime.
Declare a `Stop` hook in frontmatter:
```yaml
hooks:
Stop:
- hooks:
- type: command
command: "./scripts/cleanup.sh"
```
At runtime, this fires as `SubagentStop` in the main session context — no separate `settings.json` entry is required for per-agent cleanup logic.
---
## SubagentStart and SubagentStop in settings-json
Configure hooks in `settings.json` that respond to subagent lifecycle events in the main session. These run outside the subagent, in the main session.
| Event | Matcher input | When it fires |
| :-------------- | :-------------- | :------------------------------- |
| `SubagentStart` | Agent type name | When a subagent begins execution |
| `SubagentStop` | Agent type name | When a subagent completes |
Both events support matchers to target specific agent types by name. Omit the matcher to match all subagents.
Example — setup script fires only when `db-agent` starts; cleanup fires when any subagent stops:
```json
{
"hooks": {
"SubagentStart": [
{
"matcher": "db-agent",
"hooks": [
{ "type": "command", "command": "./scripts/setup-db-connection.sh" }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": "./scripts/cleanup-db-connection.sh" }
]
}
]
}
}
```
---
## mcpServers Field
Declare MCP servers available to a specific subagent. Each entry is either:
- A string referencing an already-configured server by name (e.g., `"slack"`)
- An inline definition with the server name as key and a full MCP server config object as value
Example referencing a pre-configured server:
```yaml
---
name: slack-notifier
description: Send Slack notifications after task completion
mcpServers:
- slack
---
```
Example with an inline server definition:
```yaml
---
name: api-agent
description: Interact with the internal API
mcpServers:
internal-api:
type: http
url: "https://api.internal/mcp"
---
```
Subagents do not inherit MCP servers from the parent session unless listed in `mcpServers`.
> **Plugin Agent Restriction**: For plugin agents (agents bundled in a plugin's `agents/` directory), the `mcpServers`, `hooks`, and `permissionMode` frontmatter fields are **silently ignored** at runtime. Claude Code strips these fields for security reasons. If you need these fields to take effect, copy the agent file to `.claude/agents/` (project-level) or `~/.claude/agents/` (user-level), where these fields ARE honored.
>
> SOURCE: [Create custom subagents](https://code.claude.com/docs/en/sub-agents.md) — "For security reasons, plugin subagents do not support the hooks, mcpServers, or permissionMode frontmatter fields. These fields are ignored when loading agents from a plugin." (accessed 2026-03-17)
---
## skills Field
Use the `skills` field to inject skill content into a subagent's context at startup. The full content of each named skill is injected — not just made available for invocation.
Subagents do not inherit skills from the parent conversation. Skills must be listed explicitly.
```yaml
---
name: api-developer
description: Implement API endpoints following team conventions
skills:
- api-conventions
- error-handling-patterns
---
Implement API endpoints. Follow the conventions and patterns from the preloaded skills.
```
Skills are identified by name, matching the skill's `name` field. The skill's full markdown body is injected into the subagent's system context before the subagent's own prompt.
---
## memory Field
The `memory` field gives the subagent a persistent directory that survives across conversations.
Choose a scope:
| Scope | Location | Use when |
| :-------- | :-------------------------------------------- | :--------------------------------------------------------------------------- |
| `user` | `~/.claude/agent-memory/<name-of-agent>/` | the subagent should remember learnings across all projects |
| `project` | `.claude/agent-memory/<name-of-agent>/` | the subagent's knowledge is project-specific and shareable via version control |
| `local` | `.claude/agent-memory-local/<name-of-agent>/` | the subagent's knowledge is project-specific but must not be version-controlled |
When `memory` is enabled:
- The subagent's system prompt includes instructions for reading and writing to the memory directory.
- The first 200 lines of `MEMORY.md` in the memory directory are injected into the subagent's system prompt, with instructions to curate `MEMORY.md` if it exceeds 200 lines.
- `Read`, `Write`, and `Edit` tools are automatically enabled so the subagent can manage its memory files.
Example:
```yaml
---
name: code-reviewer
description: Reviews code for quality and best practices
memory: user
---
You are a code reviewer. As you review code, update your agent memory with
patterns, conventions, and recurring issues you discover.
```
To prompt the subagent to use its memory, include explicit instructions in the agent body:
```markdown
Update your agent memory as you discover codepaths, patterns, library
locations, and key architectural decisions. This builds up institutional
knowledge across conversations. Write concise notes about what you found
and where.
```
---
## background Field
Set `background: true` to always run the subagent as a background task (concurrent with the main conversation).
```yaml
---
name: log-analyzer
description: Analyze application logs and report anomalies
background: true
---
```
Default is `false` (foreground, blocking).
Background subagents:
- Run concurrently while you continue working.
- Require upfront permission approval before launch — Claude Code prompts for any tool permissions the subagent will need.
- Auto-deny any permission not pre-approved once running.
- Cannot pass through `AskUserQuestion` tool calls — that tool call fails but the subagent continues.
To disable all background task functionality, set `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`.
---
## isolation: worktree Field
Set `isolation: worktree` to run the subagent in a temporary git worktree — an isolated copy of the repository.
```yaml
---
name: experimental-refactor
description: Attempt refactoring in isolation without affecting the working tree
isolation: worktree
---
```
Behavior:
- The subagent operates in a separate worktree, not the main working directory.
- If the subagent makes no changes, the worktree is automatically cleaned up on completion.
- If the subagent makes changes, the worktree persists and can be reviewed or merged.
Use `isolation: worktree` for subagents that perform destructive or exploratory changes that should not touch the user's working files.
references/openai-codex.md
# OpenAI Codex Plugin Hooks
Keep these semantics separate from Claude Code even where event names match.
## Plugin discovery and paths
- OpenAI discovers `hooks/hooks.json` by default for an enabled plugin.
- `extensions.com.openai.hooks` in root `plugin.json`, or `hooks` in the
`.codex-plugin/plugin.json` compatibility overlay, replaces default-file discovery rather than
adding to it.
- Hook paths start with `./`, resolve from the plugin root, and remain inside that root.
- Plugin hook commands receive `PLUGIN_ROOT` and persistent `PLUGIN_DATA`. Codex also supplies
`CLAUDE_PLUGIN_ROOT` and `CLAUDE_PLUGIN_DATA` for compatibility.
SOURCE: <https://developers.openai.com/plugins/build/plugins.md#bundled-mcp-servers-and-lifecycle-hooks>
(accessed 2026-09-24)
## Trust and execution
- Plugin hooks load alongside user, project, and managed hooks.
- Enabling or installing a plugin does not trust its hooks. Codex skips a non-managed hook until
the user reviews and trusts the current definition; changing the definition changes its hash and
requires another review.
- Matching hooks from multiple sources all run. Multiple matching command handlers for one event
launch concurrently, so one cannot prevent another from starting.
- Current Codex supports `command` and `mcp_tool` handlers. It parses but skips `prompt` and `agent`
handlers.
- Commands use the session working directory. Plugin-root-relative script references therefore use
`PLUGIN_ROOT` rather than assuming the current directory.
- Hook scripts must exist in the execution environment. Installing a plugin on the web does not
deploy local scripts; administrators must distribute required scripts separately.
## Platform boundaries
- Codex `PostToolUse` runs after supported tools return, including non-zero Bash exits; Claude Code
has a separate `PostToolUseFailure` event.
- Codex matching and decision support vary by event. A field accepted by Claude Code can be parsed
but unsupported in Codex, causing the hook run to fail while the operation continues.
- Synchronous hooks can block only where the event contract permits. Background hooks cannot
block, approve, rewrite, or otherwise control the triggering operation.
- Treat tool hooks as guardrails, not a complete enforcement boundary: hosted tools and specialized
paths can bypass the local function-tool hook path.
SOURCE: <https://learn.chatgpt.com/docs/hooks> (accessed 2026-09-24)
references/platform-coverage.md
# Platform Coverage Registry
Tracks which AI assistant hook systems are covered, their doc URLs, and fetch status.
Update `Last verified` when re-running fetch-and-transform-hooks-docs.sh.
## Covered platforms
| Platform | Hook concept | Doc URL | Reference file | Last verified |
|----------|-------------|---------|----------------|---------------|
| Claude Code | Yes — hooks.json + settings.json | <https://code.claude.com/docs/en/hooks.md> | claude-code.md | 2026-09-24 |
| Claude Code (inline agent) | Yes — agent frontmatter | <https://code.claude.com/docs/en/sub-agents.md> | inline-agent-hooks.md | 2026-09-24 |
| OpenAI Codex plugins | Yes — hooks.json + plugin extension/overlay | <https://learn.chatgpt.com/docs/hooks> | openai-codex.md | 2026-09-24 |
| GitHub Copilot | Yes — .github/hooks/ | <https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks.md> | github-copilot.md | 2026-02-27 |
## Fetch-attempted platforms (no hooks doc found at time of last run)
| Platform | URL attempted | Result | Notes |
|----------|--------------|--------|-------|
| Cursor | <https://docs.cursor.com/context/rules> | Pending first run | Rules system, not hooks |
| Windsurf | <https://docs.windsurf.com/windsurf/memories> | Pending first run | Memories system, not hooks |
| Amp | <https://ampcode.com/docs> | Pending first run | Unverified |
| OpenCode | <https://opencode.ai/docs/plugins.md> | Pending first run | Plugin system with async JS/TS hook functions — not a hooks.json system; see agent-plugin-ecosystem references for full coverage |
## Adding a new platform
1. Add row to the table above with URL and "Pending" status
2. Add fetch entry to scripts/fetch-and-transform-hooks-docs.sh
3. Run the script: `bash plugins/plugin-creator/skills/hooks-guide/scripts/fetch-and-transform-hooks-docs.sh`
4. Update Last verified date if reference file was created/updated
scripts/fetch-and-transform-hooks-docs.sh
#!/usr/bin/env bash
# fetch-and-transform-hooks-docs.sh
#
# Fetches AI assistant hook documentation from official sources and transforms
# each into one existing AI-facing reference file with a focused prompt.
#
# Usage: bash plugins/plugin-creator/skills/hooks-guide/scripts/fetch-and-transform-hooks-docs.sh
#
# Does NOT use set -euo pipefail — graceful partial execution: one platform
# failure should not abort the others. Each platform is handled independently.
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SKILL_DIR=$(dirname "$SCRIPT_DIR")
# ---------------------------------------------------------------------------
# Platform definitions
# Each entry: "name|url|output_reference_file_relative_to_SKILL_DIR"
# ---------------------------------------------------------------------------
PLATFORMS=(
"claude-code|https://code.claude.com/docs/en/hooks.md|references/claude-code.md"
"inline-agent|https://code.claude.com/docs/en/sub-agents.md|references/inline-agent-hooks.md"
"github-copilot|https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks.md|references/github-copilot.md"
"cursor|https://docs.cursor.com/context/rules|references/cursor.md"
"windsurf|https://docs.windsurf.com/windsurf/memories|references/windsurf.md"
"amp|https://ampcode.com/docs|references/amp.md"
)
# ---------------------------------------------------------------------------
# Tracking: accumulate failure flags for final exit code
# ---------------------------------------------------------------------------
any_failed=0
TODAY=$(date +%Y-%m-%d)
# ---------------------------------------------------------------------------
# Process each platform
# ---------------------------------------------------------------------------
for entry in "${PLATFORMS[@]}"; do
# Parse the pipe-delimited entry
platform=$(echo "$entry" | cut -d'|' -f1)
url=$(echo "$entry" | cut -d'|' -f2)
ref_rel=$(echo "$entry" | cut -d'|' -f3)
# Absolute path to the output reference file
output_path="${SKILL_DIR}/${ref_rel}"
# Temp file for the fetched content
tmp_file="/tmp/hooks-fetch-${platform}.md"
# --- Step a: Fetch the URL ---
curl --silent --fail --max-time 30 --output "$tmp_file" "$url"
curl_exit=$?
# --- Step b: Check for curl failure or empty file ---
if [ "$curl_exit" -ne 0 ] || [ ! -s "$tmp_file" ]; then
echo "SKIP ${platform}: fetch failed (curl exit ${curl_exit})"
any_failed=1
continue
fi
# --- Step c: Check minimum content size (500 bytes) ---
file_size=$(wc -c <"$tmp_file")
if [ "$file_size" -lt 500 ]; then
echo "SKIP ${platform}: response too small (likely no hooks content) — ${file_size} bytes"
any_failed=1
continue
fi
CLAUDECODE='' claude -p \
"Convert the human-facing documentation in the file at ${tmp_file} into one AI-facing reference file at ${output_path}. Rules: remove UX prose, preserve all code examples verbatim, add ToC, use imperative headings, group by concept." \
2>&1
claude_exit=$?
# --- Step e: Check for transform failure ---
if [ "$claude_exit" -ne 0 ]; then
echo "FAIL ${platform}: transform failed (claude exit ${claude_exit})"
any_failed=1
continue
fi
# --- Step f: Log success ---
echo "OK ${platform}: wrote ${output_path}"
# --- Update platform-coverage.md: set Last verified date for this platform ---
# The table rows contain the platform name in the first column.
# Match rows whose output file column matches the ref_rel basename.
ref_basename=$(basename "$ref_rel")
coverage_file="${SKILL_DIR}/references/platform-coverage.md"
if [ -f "$coverage_file" ]; then
# Replace the date in any row containing the reference file name.
# Pattern: match the row, replace the last | separated field (the date) with today.
sed -i "/${ref_basename}/s/| [0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\} *|$/| ${TODAY} |/" \
"$coverage_file"
sed -i "/${ref_basename}/s/| Pending first run *|$/| ${TODAY} |/" \
"$coverage_file"
fi
done
# ---------------------------------------------------------------------------
# Final exit code: 0 if all attempted platforms succeeded, 1 if any failed
# ---------------------------------------------------------------------------
exit "$any_failed"
SKILL.md
---
name: hooks-guide
description: Cross-platform hooks reference for AI coding assistants — Claude Code, OpenAI Codex, GitHub Copilot, Cursor, Windsurf, and Amp. Covers hook authoring in Node.js (ESM and CJS) and Python, per-platform event schemas, inline-agent hooks and MCP in agent frontmatter, common JSON I/O, exit codes, best practices, and a fetch script to refresh docs from official sources. Use when writing, reviewing, or debugging hooks for any AI assistant.
allowed-tools: Read, Grep, Glob, Bash, Write, Edit
---
## Route to Reference
```mermaid
flowchart TD
Start([What do you need?]) --> Q1{Which platform?}
Q1 -->|Claude Code| Q2{Inline agent hooks<br>hooks/mcpServers/skills/memory?}
Q1 -->|OpenAI Codex plugin| OpenAI["Read references/openai-codex.md"]
Q1 -->|GitHub Copilot| Copilot["Read references/github-copilot.md"]
Q1 -->|Cursor, Windsurf, Amp<br>or coverage gaps| Coverage["Read references/platform-coverage.md"]
Q2 -->|Yes — agent frontmatter| Agent["Read references/inline-agent-hooks.md"]
Q2 -->|No — project or global hooks| Q3{Interacts with session data?<br>costs, workloads, duration, tracking}
Q3 -->|Yes — reading or writing<br>session logs or token data| SessionSchema["Load references/claude-session-log-schema-reference.md<br>before writing any code"]
Q3 -->|No| Q4{Which language?}
SessionSchema --> Q4
Q4 -->|Node.js| CJS["Read references/hooks-nodejs-extension.md"]
Q4 -->|Python| Py["Read references/hooks-python.md"]
Q4 -->|Not sure / both| Both["Read references/hooks-nodejs-extension.md<br>then references/hooks-python.md"]
Agent --> CC["Also read references/claude-code.md<br>for full event schema"]
Q4 --> Schema["Cross-platform concepts:<br>references/common-schema.md"]
Copilot --> Schema
Coverage --> Schema
```
## Specialist Skills
For deeper Claude Code coverage, these focused skills are available:
- **hooks-core-reference** — Hook system fundamentals: events, configuration, matchers, environment variables, execution, security, debugging. Activate `/plugin-creator:hooks-core-reference` for configuration and troubleshooting.
- **hooks-io-api** — JSON input/output API: what data hooks receive via stdin and what JSON they return to control Claude. Activate `/plugin-creator:hooks-io-api` when writing hook scripts that process input or produce JSON output.
- **hooks-patterns** — Recipes and working examples: plugin hooks, frontmatter hooks, prompt-based hooks, complete code examples in Python/Node.js. Activate `/plugin-creator:hooks-patterns` for implementation patterns and examples.
## Reference Files
- `references/common-schema.md` — shared concepts, cross-platform comparison, JSON I/O, exit codes
- `references/claude-code.md` — Claude Code hooks full reference (events, matchers, configuration)
- `references/openai-codex.md` — OpenAI plugin hook discovery, trust, execution, and platform boundaries
- `references/inline-agent-hooks.md` — hooks, mcpServers, skills, and memory in agent frontmatter
- `references/github-copilot.md` — GitHub Copilot coding agent hooks
- `references/hooks-nodejs-extension.md` — Node.js authoring guide: extension rules (.mjs/.cjs), templates, anti-patterns
- `references/hooks-python.md` — Python authoring guide and templates
- `references/best-practices.md` — cross-platform conventions and anti-patterns
- `references/platform-coverage.md` — known platforms, fetch URLs, coverage status
- `references/hooks-lifecycle.png` — visual diagram of the full hook event sequence
- `references/claude-session-log-schema-reference.md` — verified schema for Claude Code session JSONL logs: file locations, record structure, tool_use/tool_result pairing, usage fields, observed response sizes. Load when creating or modifying hooks that interact with session data for costs, workloads, duration, or tracking.
## Refresh Docs
Re-fetch all platform docs and run the script's focused single-reference transform on each:
```bash
bash plugins/plugin-creator/skills/hooks-guide/scripts/fetch-and-transform-hooks-docs.sh
```
This updates reference files from official sources. Run when upstream docs change.
## Sources
- Claude Code hooks: `https://code.claude.com/docs/en/hooks.md` (accessed 2026-09-24)
- Claude Code hooks guide: `https://code.claude.com/docs/en/hooks-guide.md` (accessed 2026-09-24)
- Claude Code agent frontmatter: `https://code.claude.com/docs/en/sub-agents.md` (accessed 2026-09-24)
- OpenAI plugin hooks: `https://developers.openai.com/plugins/build/plugins.md` and `https://learn.chatgpt.com/docs/hooks` (accessed 2026-09-24)
- GitHub Copilot coding agent: `https://docs.github.com/en/copilot/using-github-copilot/using-claude-as-your-copilot-llm` (accessed 2026-02-27)