Hooks run configured behavior at session and tool events. Use them for a small, reviewed logging or validation task, with a clear owner and failure policy. This guide targets Sonny 0.12.7 and qualifies the installed Linux one-shot CLI. It does not certify every client, a signed Recipe hook chain or a deployed isolation boundary.

An operator-authored hook can run a command even when the model’s tools are disabled. Review its executable, arguments, destination and lifecycle events before enabling it. The tool guide distinguishes model tools from hook commands. The privacy guide explains the wider permission, workspace-trust and isolation boundaries.

Start with an event log

The example records only the event name and turn number. It does not copy task text, tool arguments or credential values into the log. The installed fixture validated the exact configuration, executed the helper, and observed the expected turn_start record in a private file.

Use an approved private directory on a Linux/POSIX host. In this shell example, replace the absolute directory with a new location owned by you:

umask 077
HOOK_EXAMPLE_DIR=/absolute/private/sonny-hook-example
mkdir -m 700 "$HOOK_EXAMPLE_DIR"
curl --fail --location 'https://sonny.sh/examples/hooks/print_event.py' -o "$HOOK_EXAMPLE_DIR/print_event.py"
curl --fail --location 'https://sonny.sh/examples/hooks/hooks.yaml' -o "$HOOK_EXAMPLE_DIR/hooks-example.yaml"

Read both downloaded files. Find the active configuration directory with sonny config path under the account and environment that launch Sonny. Edit the example’s two placeholder paths to the helper and private log path. Use the absolute path of your approved Python interpreter; command arguments are literal argv entries, so $HOME, ~, pipes and redirects are not expanded by a shell.

schema_version: "1"
hooks:
  - id: event-summary
    events: [turn_start]
    command: ["/usr/bin/python3", "/absolute/reviewed/print_event.py", "/absolute/private/hook-events.jsonl"]
    inject_output: false
    timeout_seconds: 10

Merge this reviewed entry into <config>/hooks.yaml, preserving any existing hooks and unique IDs. For a new file, keep it owner-only. The helper requires an existing owner-only log directory, refuses a symlink log target and checks the opened file’s owner/private mode. This permission example is tested on Linux; it does not implement Windows ACL setup.

Validate the files from the intended workspace:

sonny hooks check --json

Look for ok: true, the event-summary ID and the configuration digest. This validates loaded user/project definitions. It does not execute the command, grant workspace trust, prove that every listed hook will fire, or validate a Recipe’s separate hook chain.

Launch a new, small task using a reviewed Recipe and provider. For example, with the supplied-research Recipe saved as review-work.yaml and its model category configured:

sonny run --file ./review-work.yaml --new --no-tools --prompt "Observe the lifecycle hook for this synthetic task."

--no-tools disables model tool calls; it does not disable lifecycle hooks. After the turn, inspect the configured log. The tested example appends:

{"hook_event": "turn_start", "turn": 1}

The helper also prints that small summary on stdout. With inject_output: false the stdout is not requested as model context; the file is the example’s observable result. A configuration check or successful turn alone is weaker evidence than checking this record.

Know which source can execute

All loaded sources can contribute hooks, in user → project → Recipe order. A later source does not simply replace every earlier hook.

SourceDiscoveryCommand trust boundary
User<config>/hooks.yaml, using the platform/XDG configuration resolverOperator-authored commands are allowed by this source gate; the host’s execution boundary still matters
Project<workdir>/.sonny/hooks.yaml, for the working directory rather than an ancestor walkCommand hooks require a trusted workspace
Recipepack.hooks in the loaded RecipeCommand hooks require a trusted/signed Recipe chain and SONNY_ALLOW_PACK_EXEC=1; project-loaded content also needs workspace trust

The installed fixture observed an untrusted project command being skipped, then executed it after an automated terminal confirmation inside a disposable trust store, and skipped it again after revocation. This tests that fixture’s trust transition; it is not authorization to trust a real repository without review. For a real workspace, inspect its executable configuration first, then use sonny workspace trust from your interactive operator terminal and confirm the resolved root. sonny workspace untrust revokes that trust.

Workspace command trust does not gate the non-command inject and goal actions in the same way. Review those instructions too. Recipe command trust is distinct from ordinary tool approval and from verifying a working isolation realm. See Recipe trust and variants and MCP connection review .

Events and actions

Each hook needs an ID, one or more recognized events and exactly one action: command, inject or goal. A match regular expression can narrow tool events; anchor it when you intend an exact name. The dispatcher also tries retired tool names, such as a legacy ^bash$ matcher for canonical exec.

EventIntended pointRelease qualification here
session_start / session_endSession setup/teardownObserved once each around the tested normal CLI task
turn_start / turn_endUser turn boundariesObserved in order; the tested turn_end omits identifiers described below
pre_tool_callBefore a matched tool invocationActual read veto and later recovery tested
post_tool_callTool completion/resultAccepted event; payload shape is source-verified, separate from the read-veto check
goal_set / goal_metGoal changes and goal-aware natural turn completionRequires a goal-capable host; not a proof of the user’s real-world task outcome
round_budget_exhaustedLegacy compatibility nameAccepted by the parser; the engine-owned turn path does not emit it

A goal action cannot subscribe to goal_set; the loader rejects that loop. Do not use a goal hook as proof that another client’s goal controls or task completion have been exercised. Use the session goal guide for that workflow.

Command input and output

Commands receive one JSON object on stdin. event is a string and turn an integer. Other fields appear when relevant; consumers must handle omissions.

FieldsType and purpose
session_id, pack_id, work_dirOptional identity/context strings
tool_nameCanonical tool name for a tool event
tool_paramsNormalized parameter object; secret-valued fields can be redacted, but task content can remain private
params_schema_versionCanonical pre-tool parameter schema version; the tested read veto carries "2"
tool_params_digestDigest binding canonical parameters; present on the tested pre-tool event and intended to correlate matching post-tool metadata
tool_result, goal, final_textOptional strings that can contain sensitive task content
roundsOptional integer

The file-format schema_version: "1" is different from the canonical tool parameter version. Neither identifies a complete privacy-redaction guarantee. Avoid dumping the whole payload or environment into a diagnostic log. The example intentionally records only two fields.

Input and injection limits

The installed one-shot fixture received the correct session ID on session_start, turn_start and session_end, but not on turn_end. That event also omitted the Recipe/workspace fields in the tested path. Do not assume every lifecycle payload can be independently attributed by ID.

Command-output injection and static inject actions were also tested as positive delivery cases. The turns completed, but their injected markers did not reach the controlled model requests. Enabling inject_output on the public example likewise did not deliver its summary to the model. These positives remain failed; the working log example does not relabel them as successful context injection. Other clients need their own delivery evidence.

Veto, failure and timeout behavior

Command resultEffect
Exit 0Successful command; stdout injection is requested only when inject_output is enabled, subject to the delivery limit above
Exit 2 on pre_tool_callVetoes that tool call; bounded stderr explains the refusal to the model
Other nonzero exit or timeoutLogged; the turn continues rather than treating it as a veto

The installed read-only fixture first delivered an owned note, then withheld its body when a matched pre_tool_call command returned 2, then delivered it again after that veto was disabled. Its source file remained unchanged. This is evidence for the read veto, not a test of every write or publication effect. Tool permissions and other admission checks still apply.

Choose a timeout from 1–60 seconds; the default is 10 and the runtime caps long requests at 60. The one-second timeout and ordinary exit-3 cases both logged their failure and allowed the controlled turn to finish. Command stdout is bounded to 8 KiB and stderr to 1 KiB. Do not assume a hook timeout blocks an operation; use the appropriate trusted tool policy for a required denial.

Malformed hook files fail startup: unknown keys/types/events, duplicate IDs, unsupported versions and more than 32 hooks in a source are errors. Config files must resolve to bounded regular files. The tested unknown-key run exited 1 before a model request. SONNY_HOOKS_EMERGENCY_FAIL_OPEN is a separate audited operator exception that requires a reason and starts without hooks; it can remove veto protection as well as logging. Repairing the specific file keeps the intended behavior explicit.

Disable and recover

To disable one hook, remove its reviewed entry from the source that supplies it, then validate and launch a fresh session. To disable this whole example user file, move it out of the recognized hooks.yaml name and start a new session; preserve the file for diagnosis. The installed fixture verified this rename behavior. Project and Recipe hooks have their own sources and may still fire.

Check the log path/permissions, interpreter path, configuration error, trust message and timeout/exit report separately. A valid parser result is not proof of execution or injection. Restoring a configuration does not undo an external action already performed. Keep secrets and private content out of shared logs; use the small diagnostic procedure and the backup runbook when preserving state.

The hook format reference records the first-document-only behavior of the check, accepted timeout values versus dispatch limits, and the source-derived fields.