010 · Hooks
Lifecycle hooks (spec_hooks_010)
| Status | Implemented. Reverse-engineered from 53f8c53 (2026-09-24) and kept with the code since; its paths and names checked 2026-09-27 (//tools/specs). |
| Source | pkg/engine/tools/scripthooks.go, hookif.go; called from pkg/engine/runtime/engine.go and pkg/engine/turn.go |
| Tests | pkg/engine/tools/scripthooks_test.go, posthooks_test.go, hookdecisions_test.go; pkg/engine/hooks_test.go |
| Depends on | spec_config_002, spec_shell_007 (process guard), spec_observability_003 |
Users run their own commands at three lifecycle points: before a tool (pre_tool, can block), after a tool (post_tool, observe only), and when a prompt is submitted (prompt_submit, can block). Hooks come from trusted config.
[[hooks.pre_tool]], [[hooks.post_tool]], [[hooks.prompt_submit]], each {match, command, timeout_seconds, fail_closed}.
- HK-01
matchis a tool-name glob (path.Match), empty matches all; ignored forprompt_submit. An empty command or invalid glob is a startup error. - HK-02 Hooks run as
bash -c <command>in the workspace directory, outside the OS sandbox with the user’s full environment (they are the user’s own code), but still under the process guard. Default timeout 30 s.
- HK-10 The hook receives JSON on stdin:
event(pre_tool|post_tool|prompt_submit),session_id,workspace,tool,args,result,error,prompt(fields as applicable).
- HK-20 Block: exit code 2 (stderr is the reason, default “blocked by hook”), or exit 0 with stdout JSON
{"decision":"block","reason":"…"}. The first blocking hook wins. - HK-21 Any other failure (non-zero exit, timeout) is audited and warned, and ignored — unless
fail_closed, which blocks with “hook X failed and is fail_closed: …”. - HK-22 Each run is a span
hook <event>and is audited (hook, decisionblockor error). - HK-23
pre_toolblock: the tool doesn’t run; the model gets{"error":"blocked by pre_tool hook: <reason>"}. - HK-24
prompt_submitblock: the prompt (including steer messages) is neither recorded nor sent; one-shot runs exit 4.
- HK-30
post_toolhooks never delay the agent: the event is JSON-encoded when the tool finishes (so later mutation of result maps can’t change it) and queued for a single background worker that runs events in order. - HK-31 The worker starts on first use. The queue holds 256 events; when full, events are dropped with one terminal warning (“falling behind”) and a log entry per drop.
- HK-32 A panic in one event is contained and logged; later events still run.
- HK-33 On close, queued hooks get up to 5 s to finish, then running ones are killed.
- HK-50 Every event carries
event,session_id,workspaceandcwd,prompt_id(one per accepted prompt;tools.WithPromptID),transcript_path,permission_modeandagent. - HK-51 Synchronous:
session_start(reasonstartup,resume,new; its output — plain stdout oradditional_context— is added to the session’s next real prompt),prompt_submit(may block; its output is added to that prompt, or to a steer message),stop(after a successful non-aside turn, withstop_hook_activeafter the first; exit 2 or{"continue": true, "reason": …}sends the reason as a new prompt, recorded in the transcript as(stop hook) …, at most 5 times per prompt; cost/time limits span them,max_turnsapplies to each),permission_request(just before the user would be asked, after rules and modes;{"decision":"allow"|"deny","reason"}answers instead of the user, auditedhook-allow/hook-deny). - HK-52 Background (like
post_tool, same ordered queue and limits):post_tool_failure(a tool error or anerrorfield;post_toolstill fires for every call),session_end(reasonnew,loadwhen another session becomes active,exitat close),subagent_start(subagent,prompt) andsubagent_stop(output,error),pre_compact(reason: manual, orrewindfor/rewind’s summarize modes;prompt: the focus) andpost_compact(manual/compactand/rewindonly; the ADK’s automatic compaction has no hook point),notification(type: permission_prompt,question, orturn_finishedafter a turn longer thanui.notify_afterseconds;message). - HK-53 JSON replies:
decision(block,allow,deny,ask),reason,continue,additional_context. Blocks win; the first decision and any continue across a list of hooks count; contexts are joined.config_changedoesn’t apply (Blitz doesn’t watch its config file).
- HK-60 Replies may also carry
updated_args(an object) andsystem_message. Apre_toolreply decides the call:blockordenystops it (the model getsblocked by pre_tool hook: <reason>);updated_argsreplace its arguments in place (the first hook’s win; later hooks see them; the deny rules are checked again, and the tool still checks paths and the sandbox itself);allowlets the call through the approvals it would ask for (auditedhook-allow; keyed by the tool call’s ID, forgotten when it returns) but never past a deny or ask rule, plan mode, or the sandbox;askputs the call to the user first (MustAsk, with the reason), and when approved it counts as allowed.additional_contextgoes to the agent with the tool’s result ashook_context. Pre-tool hooks now run before an MCP tool’s approval, so theirs applies to it. - HK-61
system_messagefrom any hook is shown to the user (the turn’s notice) and logged. - HK-62
type = "http": the event is POSTed as JSON tourl(withheaders, where$VARand${VAR}are replaced only for names inallowed_env_vars; others stay as written); a 2xx body is read like a command’s stdout; other statuses and network errors are failures (fail_closedapplies). - HK-63
type = "prompt": a model judges the event againstpromptand answers like a hook reply (JSON;{"decision": ""}when the criteria don’t apply). The model ismodel(built at startup like a pinned agent’s; a warning when it can’t be, and the auto reviewer or the session’s model judges instead), else[permissions.auto] model, else the session’s; its tokens count in the session’s/cost. - HK-64
if = "<permission rule>"runs a tool hook only for calls the rule names:shell(…)against each command of the script (as the command policy parses it),read/write/delete(…)against the call’spath,web(…)against aurl’s host,skill(…),agent(…), or a tool name. A rule that doesn’t parse is a startup error. - HK-65
args = [...], instead ofcommand, runs a program directly without a shell. A hook needs what its type needs (commandorargs, not both; an http(s)url; aprompt), else startup fails. - HK-66
/hooks(REPL;WorkspaceService.ListHooks) lists every hook by event: what it runs,match,if,fail_closed, where it came from (the user’s settings, or the project file of a trusted project), and its last five failures since the workspace opened.
- HK-40
doctorchecks the first word of every command hook (all events;args’ first) is resolvable (skipped when it contains$;|&); http and prompt hooks are listed.