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.
| Source | Discovery | Command trust boundary |
|---|---|---|
| User | <config>/hooks.yaml, using the platform/XDG configuration resolver | Operator-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 walk | Command hooks require a trusted workspace |
| Recipe | pack.hooks in the loaded Recipe | Command 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.
| Event | Intended point | Release qualification here |
|---|---|---|
session_start / session_end | Session setup/teardown | Observed once each around the tested normal CLI task |
turn_start / turn_end | User turn boundaries | Observed in order; the tested turn_end omits identifiers described below |
pre_tool_call | Before a matched tool invocation | Actual read veto and later recovery tested |
post_tool_call | Tool completion/result | Accepted event; payload shape is source-verified, separate from the read-veto check |
goal_set / goal_met | Goal changes and goal-aware natural turn completion | Requires a goal-capable host; not a proof of the user’s real-world task outcome |
round_budget_exhausted | Legacy compatibility name | Accepted 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.
| Fields | Type and purpose |
|---|---|
session_id, pack_id, work_dir | Optional identity/context strings |
tool_name | Canonical tool name for a tool event |
tool_params | Normalized parameter object; secret-valued fields can be redacted, but task content can remain private |
params_schema_version | Canonical pre-tool parameter schema version; the tested read veto carries "2" |
tool_params_digest | Digest binding canonical parameters; present on the tested pre-tool event and intended to correlate matching post-tool metadata |
tool_result, goal, final_text | Optional strings that can contain sensitive task content |
rounds | Optional 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 result | Effect |
|---|---|
| Exit 0 | Successful command; stdout injection is requested only when inject_output is enabled, subject to the delivery limit above |
Exit 2 on pre_tool_call | Vetoes that tool call; bounded stderr explains the refusal to the model |
| Other nonzero exit or timeout | Logged; 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.