agent-hooks

Manage shell hooks — user scripts that run at agent lifecycle points to block, rewrite, or warn on actions, via the /hooks command.

By starchild-ai-agent · 2,155 installs

npx skills add starchild-ai-agent/official-skills --skill agent-hooks

Source repository · Upstream listing

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. Tools: read file , write file , bash 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 rm rf / destructive bash" → pre tool call block "Never let a private key get pushed to Telegram" → on outbound message block "Log every tool call for audit" → post tool call observe "Remind the agent of X at the start of every model call" → pre llm call context "Don't let the agent claim it published when it didn't" → on completion claim (in /goal ) or on stop (in normal chat) "If the answer fails my quality check, make the agent redo it" → on stop block If the user just wants a one off check, that's not a hook — hooks are for recurring, automatic lifecycle enforcement. How configuration works (the agent does it end to end) The agent installs and activates a hook with zero user copy paste. Write the script, write the config entry, then call the loopback self approve API — it flips the master switch on, approves the script for every event it's wired to, and hot mounts it live (no restart). The user just tests it afterward. ⚠️ ALWAYS use an absolute path under /data/workspace in both the yaml command: and this call. Never a relative path like skills/agent hooks/templates/security guard.py : the bridge spawns the script with the server cwd ( /app ), so a relative path resolves to /app/skills/… — an empty dir — and every spawn fails. Because the bridge fails OPEN (a script it can't run = "continue"), the guard then silently protects nothing while /hooks list still shows it "mounted". To avoid this, the standard install copies the template into /data/workspace/hooks/ and points the yaml there (see the workflow below). /app/skills is NOT the skills dir — the real one is /data/workspace/skills/ (a.k.a. /app/workspace/skills/ via symlink). command MUST be the exact command: string from the shell hooks.yaml entry (the absolute script path). The hook MUST already be declared in the yaml — approval flips a declared hook to live, it can't conjure one out of thin air. The endpoint is loopback only (same uid in container trust boundary, same as .env reads). It auto enables the master switch ( enable master defaults true), so the hook fires immediately. Approval records the script's mtime, so a later edit surfaces as drift in /hooks list / /hooks doctor — a swap the script change stays visible. This is the whole activation story for the user — there isn't a second step. After the call returns {"ok": true} , tell them it's live and to test it. Do not mention /hooks approve , /hooks on , or "two gates" — those are internal. Fallback (older builds only): if the curl returns 404 , this runtime predates the self approve API — only then fall back to asking the user to paste /hooks approve <command then /hooks on . The two gates (both handled by the self approve API — you don't surface them) Internally a hook fires only when BOTH hold; the self approve call above flips both in one shot, so the user never sees or types either : 1. Master switch ON — shell hooks.enabled: true in workspace/config/agent.yaml . The API auto enables it ( enable master defaults true). 2. Per hook approval — the (event, command) pair is recorded in the allowlist with the script's mtime (so a later edit shows as "changed since approval" drift — a swapped script stays visible). The API approves every event the command is wired to. These exist as a security boundary, not as a user step. Do NOT mention "approve" or "two gates" when explaining hooks to a user — just say you'll set it up and they can test it. (Manual /hooks on + /hooks approve exist only as the 404 fallback for older runtimes.) The /hooks command Plain text on web / Telegram / WeChat (no LLM, no cost): Command What it does /hooks or /hooks list master switch state, config path, every hook + approval/health /hooks on \ /hooks off flip the master switch (hot mount/unmount, no restart) /hooks doctor run each approved hook against a synthetic payload, check JSON /hooks approve <event <command approve + activate live (no restart) /hooks revoke <command revoke + detach live (no restart) /hooks help usage Events (12) and what each can do Event Fires Capability stdin gives the script on user message a user message arrives, before the model sees it block / rewrite text message , channel pre tool call before a tool runs block / rewrite input tool name , tool input post tool call after a tool runs observe (log/metrics) tool name , tool result transform tool result result before agent sees it append a note tool name , tool result pre llm call before a model call inject context system , last user message , model post llm call after a model reply observe / swap model on response end final reply assembled, once per turn rewrite reply response , model , tokens , tool names on stop turn boundary, after on response end block → force a redo response , tool names , stop hook active on outbound message before a TG/WeChat push block / rewrite outbound notification , type on completion claim agent claims a /goal done block → force a redo goal , summary , response , tool names on session start session begins observe status on session end session ends observe / cleanup status Every payload also includes event , session id , agent id , cwd . The event field is the dispatch key. It names which lifecycle moment is firing ( pre tool call , on user message , …). A multi event script (like security guard.py , one file wired to five events) reads event to decide which branch to run — no event in the payload means no branch matches, so the script falls through to "continue" (empty output = allow). The runtime always sets it; you only have to remember it when hand crafting a test payload (see the dry run step below). It is NOT something you put in shell hooks.yaml — there the event: key tells the bus when to call you; the event field in the payload is the bus telling the script which moment it is. The three "make the agent fix it" levers (don't mix them up) These three fire near the end of a turn but have very different power — pick by what you need to happen when something's wrong: Event Power Use when on response end rewrite only — edit the stored/forwarded reply (footer, redaction, mask). Cannot make the agent redo. Zero loop risk. You only need to change the text (mask a leaked key, add a cost footer). on stop block → redo, in normal chat — steers your reason back as the next instruction and the agent keeps working. Kernel capped (≤3 redos/turn) + stop hook active flag, so it can't trap a turn. You need the agent to actually fix/verify its own output in ordinary conversation (quality gate, citation/publish check). Claude Code "Stop" hook parity. on completion claim block → redo, in /goal only — refuses a fabricated "done" and keeps the goal loop running. Same redo power, but it only fires inside a running /goal supervisor loop. Rule of thumb: mask → on response end ; redo in chat → on stop ; redo in a goal → on completion claim . Note on response end can only rewrite the stored copy — tokens already streamed to a live web client can't be unsent, so prefer on stop when you need the user to actually see a corrected answer. Output protocol (what the script prints on stdout) JSON object, or empty for "continue". Fields: context is agent facing (goes into the prompt, pre llm call only). systemMessage / add warning is user facing (shown to the human on the tool result / completion / outbound surfaces) — never injected into the prompt. Safety: scripts run with shell=False + 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. Writing a readable reason The reason is shown to the user (on the blocked action card) and to the model . Keep it short and scannable — one clause for why , then the evidence. Don't write paragraphs: a reason fires on a card the user is already annoyed to see, and a wall of text buries the actual cause. Aim for the shape [tag] Blocked (<why ): <evidence — ~8–12 words before the colon, never two sentences of hand wringing. Avoid (verbose) Prefer (concise) This command is irreversible and would cause permanent data loss, so I've blocked it: rm rf / Blocked (recursive force delete): rm rf / That message contains what looks like an API key, private key, or seed phrase. I won't process it — treat it as exposed and rotate it. Blocked: message contains a credential. Rotate it. You shared a preview link whose id isn't in the registry — it looks made up. Serve the preview first and use its real id Preview id not in the registry. Serve it first: /preview/x/ The model still gets enough to act (the why + the offending payload); the user gets a card they can read at a glance. The UI splits one reason string into two parts for you, so you don't parse anything client side: Explanation + command box — put the human sentence first, then : , then the offending command/payload. Everything after the first ": " is rendered in a separate monospace box. The split only triggers when that tail looks like a payload (has a space or is longer than ~12 chars), so an ordinary sentence that happens to contain a colon is left intact. Sentence only — a reason with no ": " shows as a single sentence and no command box. That's the right shape when there's nothing to quote (e.g. a pasted seed phrase). [tag] is stripped — a leading tag like [security] is removed before display and the sentence is auto capitalised, so you can keep a tag for your own grep without it leaking into the UI. A hook that doesn't follow this still works — a plain string just renders as one sentence. The convention only unlocks the nicer "explanation + command" layout. Config file format workspace/config/shell hooks.yaml : 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. HTTP specifics: SSRF guard — the URL must be http(s) and must NOT resolve to a loopback / private / link local (incl. cloud metadata 169.254.169.254 ) / 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 ; /hooks list shows it as POST <url 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 /chat . Why proxy direct: OpenRouter is an external stateless API, so it does not re enter the agent loop or fire pre llm call no recursion , one cheap completion instead of a full agent turn, y