Hooks
Hooks let you run your own shell commands at specific points in a conversation — before a tool runs, after it finishes, when a prompt is submitted, and more. Two common uses:
- Guardrails — block a risky shell command or a file edit before it happens.
- Audit logging — record every tool call and its result to a file, SIEM, or webhook.
Hooks are configured in your settings file under the hooks
key. They are Claude Code / Codex compatible in shape, so many existing hook scripts work with
little or no change — see Compatibility with Claude Code for
the differences.
Hooks are inert until you configure one — there’s nothing to turn on.
Events
| Event | Fires when | Can block? |
|---|---|---|
SessionStart | A conversation is activated (opened, or reopened after being evicted from memory) | No |
SessionEnd | A conversation’s runtime is torn down (evicted, deleted, or on app shutdown) | No |
UserPromptSubmit | A prompt is accepted for a turn (typed, queued, or slash-command) | Yes — rejects the prompt |
PreToolUse | Before a local tool call executes | Yes — denies, or forces confirmation |
PostToolUse | After a local tool call finishes (success or failure) | Feedback only — the tool already ran |
Stop | The main conversation would stop responding | Yes — forces it to keep going |
SubagentStart | A subagent (from launchSubagent or delegated webSearch) starts | No |
SubagentStop | A subagent would stop responding | Yes — forces it to keep going |
Notification | A confirmation dialog is shown, or the AskUser tool poses a question | No |
Claude Code also has a PreCompact event; Posit Assistant doesn’t fire it in this release.
Configuration
Add a hooks key to your global (~/.posit/assistant/settings.json) or project
(.posit/assistant/settings.json) settings file. Each event maps to a list of matcher
groups, and each group runs one or more command handlers:
{
"hooks": {
"PreToolUse": [
{
"matcher": "bash",
"hooks": [{ "type": "command", "command": ".posit/hooks/check-cmd.py", "timeout": 30 }]
}
],
"PostToolUse": [
{ "hooks": [{ "type": "command", "command": ".posit/hooks/audit-log.sh" }] }
]
}
}
Example: block a dangerous command
A PreToolUse hook that reads the tool call from stdin and exits 2 to deny it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "bash",
"hooks": [{ "type": "command", "command": ".posit/hooks/block-rm.py" }]
}
]
}
}
#!/usr/bin/env python3
import json, sys
payload = json.load(sys.stdin)
command = payload.get("tool_input", {}).get("command", "")
if "rm -rf" in command:
print("Refusing to run rm -rf", file=sys.stderr)
sys.exit(2) # exit 2 = deny the tool call
Example: audit log every tool call
A PostToolUse hook with no matcher (matches every tool) that appends a line per call:
{
"hooks": {
"PostToolUse": [{ "hooks": [{ "type": "command", "command": ".posit/hooks/audit-log.sh" }] }]
}
}
#!/usr/bin/env bash
cat >> .posit/hooks/audit.jsonl
Example: macOS alerts for notifications and completion (macOS)
A Notification hook that plays a sound and shows a macOS notification when a confirmation
dialog or AskUser question appears, and a Stop hook that does the same when the conversation
stops responding:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "afplay /System/Library/Sounds/Funk.aiff & osascript -e 'display notification \"Posit Assistant needs your attention\" with title \"Posit Assistant\"'"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff & osascript -e 'display notification \"Posit Assistant finished work\" with title \"Posit Assistant finished\"'"
}
]
}
]
}
}
Matchers
The matcher field selects which calls a group applies to:
- Omitted,
"", or"*"— matches everything. - Letters, digits,
_,-, spaces,,, and|only — an exact match, or a,/|-separated list of exact matches (e.g."bash|executeR"). - Anything else — treated as a JavaScript regular expression.
What a matcher is compared against depends on the event: tool name for PreToolUse and
PostToolUse (e.g. bash, read, edit, executeR, or mcp__<server>__<tool> for MCP tools),
agent type for SubagentStart and SubagentStop, source for SessionStart, reason for
SessionEnd, notification type for Notification. UserPromptSubmit and Stop ignore
matcher.
Handler fields
Each entry in a group’s hooks array is a command handler:
| Field | Required | Description |
|---|---|---|
type | No | Must be "command" if present (the only handler type supported) |
command | Yes | The command to run. Used as-is with shell: true (system shell), or as the executable when args is present |
args | No | Argument vector. When present, command is spawned directly (no shell) with args as its arguments |
timeout | No | Per-hook timeout, in seconds. Defaults to 30s for UserPromptSubmit, 600s for every other event |
Any other field (Claude’s if, async, asyncRewake, shell selection, or anything
unrecognized) causes that handler to be skipped entirely, with a warning — rather than
silently running it more broadly, at a different time, or under a different shell than it was
written for.
Global and project hooks combine
Unlike permission rules (where project settings override global settings), hook configuration is additive: global hooks run first, then project hooks, for the same event. A project can’t disable a hook defined globally — only add more.
Hook input
Every hook receives a JSON payload on stdin. Common fields on every event:
| Field | Description |
|---|---|
session_id | The conversation ID |
transcript_path | Path to the conversation’s transcript file, or null if it doesn’t have one yet |
cwd | The workspace root |
hook_event_name | The event name (e.g. "PreToolUse") |
PreToolUse, PostToolUse, UserPromptSubmit, and Stop additionally include
permission_mode, with a value of "normal", "auto", "yolo", or "restricted" — see
Permissions & Trust.
Per-event fields:
| Event | Extra fields |
|---|---|
PreToolUse | tool_name, tool_input, tool_use_id |
PostToolUse | tool_name, tool_input, tool_use_id, tool_response (capped at 50,000 characters) |
UserPromptSubmit | prompt |
SessionStart | source: "startup" or "resume" |
SessionEnd | reason: "evict", "delete", "shutdown", or "other" |
Stop | stop_hook_active, last_assistant_message |
SubagentStart | agent_id, agent_type |
SubagentStop | agent_id, agent_type, stop_hook_active, last_assistant_message |
Notification | notification_type: "permission_prompt" or "agent_needs_input", message |
Hook output
A hook communicates back through its exit code and stdout:
- Exit 0: stdout is parsed as JSON if possible (see below). For
SessionStartandUserPromptSubmit, plain (non-JSON) stdout text is used directly as additional context. - Exit 2: a blocking signal, with stderr shown as the reason. Only meaningful on events that can block (see the table above) — other events ignore it.
- Any other exit code: treated as a non-blocking error — a warning is logged and the turn proceeds.
JSON stdout fields
When a hook’s stdout is valid JSON, these fields are recognized:
| Field | Effect |
|---|---|
systemMessage | Shown to the user as a notification |
continue: false | Terminal stop — see below |
stopReason | Shown alongside continue: false |
hookSpecificOutput | Event-specific fields, below |
hookSpecificOutput.hookEventName is required whenever hookSpecificOutput is present, and
must match the event that ran the hook — a mismatch is ignored with a warning.
| Event | hookSpecificOutput fields |
|---|---|
PreToolUse | permissionDecision: "allow", "deny", or "ask"; permissionDecisionReason; additionalContext |
PostToolUse | top-level decision: "block" + reason; additionalContext |
UserPromptSubmit | top-level decision: "block" + reason; additionalContext |
SessionStart | additionalContext |
Stop / SubagentStop | top-level decision: "block" + reason |
additionalContext is capped at 10,000 characters per hook, and 25,000 characters total across
all hooks matching one event — overflow is truncated with a warning.
continue: false is a terminal stop: it takes precedence over every other decision from that
dispatch and ends processing right after the hook runs, not at the next convenient point. On
SessionStart, SessionEnd, SubagentStart, and Notification (which have no turn to stop) it
is parsed but ignored, with a warning.
PreToolUse allow/ask/deny
denyblocks the tool call.askforces a confirmation dialog in front of the user — this bypasses auto mode’s classifier and YOLO mode’s auto-approval. (In headless mode, where there’s no one to ask, it auto-approves per headless mode’s existing behavior.)allowskips an ordinary confirmation prompt — but it can never turn adenyinto an allow, override restricted mode, override a workflow mode’s own restrictions (like Ask mode’s read-only guarantee), or bypass the warning shown for an explicitly dangerous/sandbox-bypassing command. It only downgrades a plain “do you want to allow this?” prompt.
When more than one hook matches the same event, every matching hook runs — a blocking
guardrail never prevents a later audit-logging hook from also running. If hooks disagree, deny
wins over ask, which wins over allow.
Timeouts and budgets
Each hook has its own timeout (30s default for UserPromptSubmit, 600s for everything else,
overridable per-handler with timeout). On top of that, the whole dispatch for one event has a
combined budget equal to the larger of the event’s default and the longest configured timeout
among the hooks that matched — hooks run one at a time, and any hook that hasn’t started before
the budget runs out is skipped with a warning.
SessionEnd is the exception: it always has an absolute 5-second budget, regardless of any
per-hook timeout — conversations are being torn down at that point, so hooks can’t hold that up
indefinitely.
Security model
- Hooks run with your own privileges. Posit Assistant does not vet, sandbox, or review hook commands before running them — a hook is exactly as trusted as any other script on your machine.
- Project hooks require a trusted workspace. Opening a workspace for the first time shows a trust prompt (see Workspace Trust); in restricted mode, project-level hooks are skipped entirely. Hooks defined in your global settings file still run — restricted mode only excludes the project’s own configuration.
- Fail-open. If a hook crashes, times out, or exits with an unrecognized code, Posit Assistant logs a warning and continues as if the hook had allowed the action. A guardrail hook that crashes does not stop the turn — write guardrail hooks with this in mind, and don’t treat “the hook is installed” as a hard guarantee.
V1 scope: what hooks can see
Hooks in this release only see prompt text and local tool calls:
UserPromptSubmitinspects the text you typed — file attachments and selected/workspace context (open files, environment info) sent along with the turn are assembled afterward and are not passed to the hook.PreToolUse/PostToolUseonly fire for tools Posit Assistant executes locally. Tools a model provider executes on its own servers (for example, some providers’ built-in web search) never reach these hooks.- Automatic resubmissions Posit Assistant makes on your behalf — auto-compaction, or replaying
an edited message — do not fire
UserPromptSubmit. Only prompts you (or something acting on your behalf, like a queued prompt) submit go through it.
A future release may extend hooks to see the fully-assembled request sent to the model provider, and to observe provider-executed tools.
Environment
Hook commands run with your normal shell environment, plus one addition:
PA_PROJECT_DIR— the workspace root.
Posit Assistant does not set or expand CLAUDE_PROJECT_DIR (including
${CLAUDE_PROJECT_DIR} placeholders in args), even though Claude Code does.
Disabling hooks
Set the environment variable PA_DISABLE_HOOKS=1 before starting Posit Assistant to disable all
hook execution — useful for CI, automated testing, or debugging.
Compatibility with Claude Code
Hook configuration follows Claude Code’s hook format
closely enough that many existing hook scripts work without changes: the config shape, payload
field names (including tool_response), matcher rules, exit-code semantics, and JSON output
field names all match. It is a documented compatible subset, not an exact reimplementation.
Known differences:
permission_modeuses Posit Assistant’s own approval-mode names (normal,auto,yolo,restricted) instead of Claude’s mode strings.prompt_id,model(except onSessionStartin Claude), andturn_idare not emitted.- Handler fields that change how or when a command runs —
if,async,asyncRewake, andshell(Bash/PowerShell selection) — are not supported. A handler using any of them is skipped entirely, with a warning, rather than partially honored. - Shell-form hooks (no
args) always run under the system shell (cmd.exeon Windows), not Claude’s separate Bash/PowerShell choice. CLAUDE_PROJECT_DIRis neither set nor expanded; usePA_PROJECT_DIRinstead.continue: falseonSessionStart,SessionEnd,SubagentStart, andNotificationis parsed but ignored — those events have no turn to stop.PostToolUsefires whether the tool succeeded or failed (tool_responsecarries the error); there is no separatePostToolUseFailureevent.SessionEnd’s total budget is an absolute 5 seconds, vs. Claude’s 1.5-second shared budget (which per-hook timeouts can raise up to 60 seconds).- Hooks matching the same event run sequentially, not concurrently.
updatedInput/updatedToolOutput(rewriting a tool’s input or output) are not supported.suppressOutputis accepted but has no effect — Posit Assistant doesn’t show raw hook stdout in the transcript by default regardless.PreCompactis not fired.
Hook config entries using unsupported handler types (http, prompt, mcp_tool, agent) or
unknown event names are skipped with a warning rather than causing an error, so a settings file
shared with Claude Code mostly continues to work — only the unsupported pieces are ignored.