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

EventFires whenCan block?
SessionStartA conversation is activated (opened, or reopened after being evicted from memory)No
SessionEndA conversation’s runtime is torn down (evicted, deleted, or on app shutdown)No
UserPromptSubmitA prompt is accepted for a turn (typed, queued, or slash-command)Yes — rejects the prompt
PreToolUseBefore a local tool call executesYes — denies, or forces confirmation
PostToolUseAfter a local tool call finishes (success or failure)Feedback only — the tool already ran
StopThe main conversation would stop respondingYes — forces it to keep going
SubagentStartA subagent (from launchSubagent or delegated webSearch) startsNo
SubagentStopA subagent would stop respondingYes — forces it to keep going
NotificationA confirmation dialog is shown, or the AskUser tool poses a questionNo

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:

FieldRequiredDescription
typeNoMust be "command" if present (the only handler type supported)
commandYesThe command to run. Used as-is with shell: true (system shell), or as the executable when args is present
argsNoArgument vector. When present, command is spawned directly (no shell) with args as its arguments
timeoutNoPer-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:

FieldDescription
session_idThe conversation ID
transcript_pathPath to the conversation’s transcript file, or null if it doesn’t have one yet
cwdThe workspace root
hook_event_nameThe 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:

EventExtra fields
PreToolUsetool_name, tool_input, tool_use_id
PostToolUsetool_name, tool_input, tool_use_id, tool_response (capped at 50,000 characters)
UserPromptSubmitprompt
SessionStartsource: "startup" or "resume"
SessionEndreason: "evict", "delete", "shutdown", or "other"
Stopstop_hook_active, last_assistant_message
SubagentStartagent_id, agent_type
SubagentStopagent_id, agent_type, stop_hook_active, last_assistant_message
Notificationnotification_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 SessionStart and UserPromptSubmit, 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:

FieldEffect
systemMessageShown to the user as a notification
continue: falseTerminal stop — see below
stopReasonShown alongside continue: false
hookSpecificOutputEvent-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.

EventhookSpecificOutput fields
PreToolUsepermissionDecision: "allow", "deny", or "ask"; permissionDecisionReason; additionalContext
PostToolUsetop-level decision: "block" + reason; additionalContext
UserPromptSubmittop-level decision: "block" + reason; additionalContext
SessionStartadditionalContext
Stop / SubagentStoptop-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

  • deny blocks the tool call.
  • ask forces 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.)
  • allow skips an ordinary confirmation prompt — but it can never turn a deny into 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:

  • UserPromptSubmit inspects 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/PostToolUse only 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_mode uses Posit Assistant’s own approval-mode names (normal, auto, yolo, restricted) instead of Claude’s mode strings. prompt_id, model (except on SessionStart in Claude), and turn_id are not emitted.
  • Handler fields that change how or when a command runs — if, async, asyncRewake, and shell (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.exe on Windows), not Claude’s separate Bash/PowerShell choice.
  • CLAUDE_PROJECT_DIR is neither set nor expanded; use PA_PROJECT_DIR instead.
  • continue: false on SessionStart, SessionEnd, SubagentStart, and Notification is parsed but ignored — those events have no turn to stop.
  • PostToolUse fires whether the tool succeeded or failed (tool_response carries the error); there is no separate PostToolUseFailure event.
  • 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.
  • suppressOutput is accepted but has no effect — Posit Assistant doesn’t show raw hook stdout in the transcript by default regardless.
  • PreCompact is 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.