Agent Hooks
Shell hooks let a user run their own script at fixed points in the agent's
lifecycle — to block a dangerous action, rewrite an input or an outbound
message, inject context into the model, or warn the user. The script can
be written in any language; it talks to the agent over a simple JSON-on-stdin,
JSON-on-stdout protocol.
When to use
Reach for hooks when the user wants the agent to automatically enforce a rule
or react to an event without being asked each time. Examples:
- "Stop me from running / destructive bash" → block
- "Never let a private key get pushed to Telegram" → block
- "Log every tool call for audit" → observe
- "Remind the agent of X at the start of every model call" → context
- "Don't let the agent claim it published when it didn't" →
If the user just wants a one-off check, that's not a hook — hooks are for
recurring, automatic lifecycle enforcement.
How configuration works (who does what)
The agent prepares everything; the user activates. This split is a
deliberate security property — slash commands are parsed before the LLM sees
them, so prompt injection cannot forge an activation.
| Agent does (in conversation) | User must type (activation) |
|---|
| Write the hook script (any language) | /hooks approve <event> <command>
|
Write/extend workspace/config/shell_hooks.yaml
| (or the Preferences toggle) |
| Dry-run the script via | (verify) |
| Explain / debug | |
A hook is arbitrary code execution in the container, so the
"who may activate"
gate is reserved for a real human. Always finish by handing the user the exact
and
commands to paste.
The two gates
A hook fires only when BOTH hold:
- Master switch ON —
shell_hooks.enabled: true
in
workspace/config/agent.yaml
(flipped by or the Preferences
toggle; legacy env forces it on as a fallback).
- Per-hook approval — each pair approved once via
(recorded with the script's mtime, so a later edit is flagged
"changed since approval" — a swap-the-script attack is visible).
Switch on but unapproved → inert (shows ✗ in
). Approved but switch
off → inert until
.
The command
Plain text on web / Telegram / WeChat (no LLM, no cost):
| Command | What it does |
|---|
| or | master switch state, config path, every hook + approval/health |
| | | flip the master switch (hot mount/unmount, no restart) |
| run each approved hook against a synthetic payload, check JSON |
/hooks approve <event> <command>
| approve + activate live (no restart) |
| revoke + detach live (no restart) |
| usage |
Events (9) and what each can do
| Event | Fires | Capability | stdin gives the script |
|---|
| before a tool runs | block / rewrite input | , |
| after a tool runs | observe (log/metrics) | , |
| result before agent sees it | append a note | , |
| before a model call | inject context / block | , , |
| after a model reply | observe | |
| before a TG/WeChat push | block / rewrite outbound | , |
| agent claims "done" | refuse a fake completion | , , , |
| session begins | observe / inject | |
| session ends | observe / cleanup | |
Every payload also includes
,
,
,
.
Output protocol (what the script prints on stdout)
JSON object, or empty for "continue". Fields:
jsonc
{"decision": "block", "reason": "..."} // deny the action / refuse completion
{"tool_input": {...}} // pre_tool_call: rewrite EXISTING input keys
{"notification": "..."} // on_outbound_message: rewrite the message
{"context": "..."} // pre_llm_call: inject into prompt (AGENT-facing)
{"systemMessage": "..."} // allow, but show the USER a note
{"add_warning": "..."} // same user-facing note channel
<empty> // continue, no change
is agent-facing (goes into the prompt,
only).
/ is user-facing (shown to the human on the
tool-result / completion / outbound surfaces) — never injected into the prompt.
Safety: scripts run with
+ argv split (no shell injection) and a
per-hook timeout. A script that errors, times out, or prints non-JSON falls
through to
continue — a broken hook can never break the agent.
Config file format
workspace/config/shell_hooks.yaml
:
yaml
hooks:
- event: pre_tool_call
matcher: "rm -rf|dd if=|mkfs" # optional regex; script only spawns on a match (perf gate)
command: ./extensions/shell_hooks/examples/block_secrets.py
timeout: 10 # seconds, default 20, max 120
Two hook transports
A hook is either a local command (default) or an HTTP endpoint — same
payload in, same decision JSON out, only the transport differs.
yaml
hooks:
- event: pre_tool_call
type: http # omit type -> "command" (default)
url: https://my-guard.example.com/hook
timeout: 10
HTTP specifics:
- SSRF guard — the URL must be http(s) and must NOT resolve to a loopback /
private / link-local (incl. cloud metadata ) / reserved
address (blocked at parse AND call time). Set
STARCHILD_SHELL_HOOKS_HTTP_ALLOW_LOCAL=1
only to intentionally hit a local
service.
- Approval keys on the URL:
/hooks approve <event> <url>
;
shows it as and skips the executable/mtime checks.
Adding an LLM judgement (call the proxy, NOT /chat)
When a hook needs real reasoning ("does this leak a secret?", "is this
completion actually done?"), call an LLM
directly through the proxy from your
script — never the agent's own
.
python
from core.http_client import proxied_post
import json, sys
event = json.load(sys.stdin)
r = proxied_post(
"https://openrouter.ai/api/v1/chat/completions",
json={
"model": "minimax/minimax-m3", # cheap default (~$0.0002/call)
"messages": [
{"role": "system", "content":
'You are a guard. Output ONLY JSON {"decision":"block|allow","reason":"..."}.'},
{"role": "user", "content": json.dumps(event)},
],
"temperature": 0, "max_tokens": 200,
},
headers={"SC-CALLER-ID": "chat:hook"}, # required for billing
timeout=40,
)
try:
print(json.dumps(json.loads(r.json()["choices"][0]["message"]["content"])))
except Exception:
print("{}") # fail-open on any parse error
Why proxy-direct: OpenRouter is an external stateless API, so it does
not
re-enter the agent loop or fire
->
no recursion, one cheap
completion instead of a full agent turn, your own prompt + pure-JSON response.
Calling
from a hook re-emits the same event (the bridge guards against
the loop, but it's needless overhead) — and an LLM hook that calls
must
never sit on
. See the host docs
section
"Calling an LLM through the proxy".
Standard workflow (the agent's checklist)
- Clarify the rule and pick the event from the table above.
- Write the script — read JSON on stdin, print a decision on stdout.
Exit non-zero / non-JSON = continue. Make it executable ().
- Add a config entry in
workspace/config/shell_hooks.yaml
(add a
regex when possible so the script only spawns when relevant).
- Dry-run it yourself with — pipe a sample JSON payload into the
script and confirm it prints valid JSON. (The agent CANNOT run —
that is a user-typed command. Ask the user to run to verify
after approval.)
- Hand the user the activation commands to paste:
/hooks approve <event> <command>
/hooks on
- Confirm with that it shows ✓ approved and live.
Ready-made example scripts
Shipped in-repo under
extensions/shell_hooks/examples/
(copy + adapt):
| Script | Event | Purpose |
|---|
| multi | private-key / seed-phrase guard (inbound warn + outbound/tool hard-block) |
| | block "claimed published but no publish tool ran / cited a fake URL" |
inject_website_reminder.sh
| | inject a context note when a published site exists |
For any other rule (block dangerous bash, audit tool calls, redact outbound
PII, warn on prod writes, ...), write a fresh script — the minimal block
example below is the template, and the output protocol above covers every
capability.
Minimal block example (, any language)
bash
#!/usr/bin/env bash
payload="$(cat)"
python3 - "$payload" <<'PY'
import json, sys, re
ev = json.loads(sys.argv[1])
cmd = (ev.get("tool_input") or {}).get("command", "")
if re.search(r"rm\s+-rf\s+/|dd\s+if=|mkfs", cmd):
print(json.dumps({"decision": "block", "reason": f"refusing destructive command: {cmd}"}))
else:
print("{}") # continue
PY
Claude Code compatibility
Hook scripts written for Claude Code work unchanged — their output is
auto-translated into the fields above:
| Claude Code output | Translated to |
|---|
hookSpecificOutput.permissionDecision: "deny"
(+ ) | (+ ) |
hookSpecificOutput.additionalContext
| |
hookSpecificOutput.updatedInput
| (rewrite) |
| (+ ) | (+ ) |
| (user-facing note) |
| no-op (our stdout never enters the transcript) |
| exit code 2 with stderr, no stdout | , stderr is the reason |
Only the output
payload is translated — event NAMES stay ours
(
, not
).
Troubleshooting "my hook never fires"
- Is the event one of the 9 above? (a typo is a silent no-op)
- Is the master switch on? shows it; to enable.
- Is the hook approved? in → .
- Does the regex actually match? Too narrow = never spawns.
- Run — it flags non-executable / tampered / timed-out / non-JSON.
Deep reference
Full protocol, security model, and per-event payload detail live in the agent's
own docs:
config/context/references/agent-hooks.md
(read it for edge cases this
skill summarizes).