Extending
Hooks
Shell commands attached to agent lifecycle events, with a strict declaration format, a JSON payload on stdin and one structured output shape.
Declaring hooks
Hooks are shell commands attached to agent lifecycle events. User declarations live in the agent configuration directory’s hooks.json (normally ~/.notagent/agent/hooks.json). Project declarations are loaded once from .notagent/hooks.json relative to the directory in which notagent was launched; they are not reloaded when a session uses another working directory. User hooks run first and project hooks are appended.
Project hook loading is currently independent of project trust. Review .notagent/hooks.json before starting notagent in an unfamiliar checkout, because its commands may run even when that project has not been trusted.
A file can be a plain array or an object with a hooks array:
[
{
"event": "PreToolUse",
"matcher": "write,edit",
"command": "check-change",
"timeout_ms": 30000
}
]
{
"hooks": [
{
"event": "SessionStart",
"command": "record-session-start",
"timeout_ms": 5000
}
]
}
event and command are required. timeout_ms is an integer from 1 through 300000 and defaults to 30000. matcher is an exact, comma-separated list of tool names; * matches every tool. It is valid only for PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionResult. Matchers are not regular expressions. Unknown fields, empty matchers, invalid timeouts, and matchers on other events reject that declaration without discarding valid sibling declarations. Exact duplicates are reported but both still run.
This contract is intentionally strict: the old timeout field is invalid and is not interpreted as seconds or milliseconds.
The payload
Every hook receives one JSON object on standard input. All payloads contain session_id, cwd, and hook_event_name; transcript_path is present once a session has a file on disk. Event-specific fields are:
| Events | Additional fields |
|---|---|
SessionStart |
source |
SessionEnd |
reason |
TurnStarted |
turn_number |
UserPromptSubmit |
prompt |
UserPromptQueued |
prompt, queue, image_count, queue_length |
PreToolUse |
tool_call_id, tool_name, tool_input |
PostToolUse, PostToolUseFailure |
tool_call_id, tool_name, tool_input, tool_response |
PermissionRequest |
tool_call_id, tool_name, target, policy, reason, mode |
PermissionResult |
tool_call_id, tool_name, target, policy, answer, allowed |
TaskStarted |
task_id, kind, description, detached |
SubagentStart |
task_id (null without a task manager), child_session_id, agent, alias, description, prompt, detached |
SubagentStop |
task_id (null without a task manager), child_session_id, agent, alias, status, duration_ms, stop_reason, result, detached |
PreCompact, PostCompact |
trigger, reason; PreCompact also has custom_instructions |
Stop, StopFailure, Interrupt |
stop_hook_active |
Notification |
notification_type, message, and, for permission prompts, tool_name |
Ordering and blocking
Hook commands run sequentially in declaration order. Observational hook chains run every matching declaration even when one fails. Blocking chains stop at the first denial or cancellation; an allow or ask does not suppress a later denial. PreToolUse and UserPromptSubmit are blocking; every other event is observational. Exit code 2 or a timeout refuses a blocking event. On an observational event, every non-zero exit and every timeout is reported as a fault instead. Cancelling a turn cancels its hook without manufacturing a denial. Timeout and cancellation terminate the hook’s process tree. Stop is only a notification and cannot continue or restart the agent.
After an ordinary hook exit, a deliberately detached descendant with redirected standard streams is left running. If a descendant retains the hook’s captured stdout or stderr pipe, the runner waits two seconds for those readers, then terminates the owned process tree so the hook invocation cannot hang forever.
Structured output
A decision or explicitly marked context uses exactly one structured-output shape:
{
"hook_output": {
"decision": "deny",
"reason": "The requested operation is outside the approved path.",
"context": "Optional context for the model."
}
}
decision is optional and accepts allow, ask, or deny; reason and context are optional strings. The entire trimmed standard output must be this JSON document. Mixed log lines followed by JSON are plain output, and top-level decision or permission fields are invalid. JSON-shaped malformed output is reported and refuses a blocking event. On observational events a decision is reported and ignored. ask enters the existing approval flow for PreToolUse, but is invalid and refuses UserPromptSubmit.
In the application, only successful output from UserPromptSubmit is delivered to the model as context: plain stdout is used directly, while structured JSON contributes only its context field. Context from PreToolUse and observational events is not injected into the conversation. Stdout and stderr are each capped at 4000 UTF-16 code units including the truncation marker before stdout is interpreted. A structured JSON document must therefore fit inside that captured stdout limit; one cut by the limit is malformed and refuses a blocking event. Tool responses and subagent result summaries are capped at 2000 UTF-16 code units including their truncation marker. Commands should write diagnostics to stderr. For an exit-code refusal, structured reason takes precedence, then stderr, then plain stdout.
Examples
A read-only prompt guard can reject an oversized submission before it reaches the transcript or model:
{
"event": "UserPromptSubmit",
"command": "jq -e '.prompt | length <= 2000' >/dev/null || { echo 'Prompt is too long' >&2; exit 2; }",
"timeout_ms": 5000
}
An observational hook can append a bounded audit record after a tool finishes:
{
"event": "PostToolUse",
"matcher": "write,edit",
"command": "jq -c '{id: .tool_call_id, tool: .tool_name, success: .tool_response.success}' >> .notagent/hook-audit.jsonl",
"timeout_ms": 5000
}