$npx -y skills add tkellogg/open-strix --skill hook-creatorCreate and manage runtime command hooks declared in skills/*/hooks.json. Use when the user asks to intercept tool calls, augment prompts, audit or mutate tool arguments/results, add startup/shutdown scripts, or debug hook behavior.
| 1 | # Hooks |
| 2 | |
| 3 | Hooks are short-lived commands that receive one runtime event as JSON on stdin and may write one JSON object to stdout to replace it. They live in skills as `hooks.json` files and are discovered at startup and when `reload_hooks` is called. |
| 4 | |
| 5 | Use hooks for small runtime adapters: |
| 6 | |
| 7 | - pre/post tool-call policy |
| 8 | - prompt augmentation before the model is invoked |
| 9 | - send_message shaping or audit |
| 10 | - metrics and local logging |
| 11 | - startup/shutdown setup and teardown |
| 12 | |
| 13 | Do not use hooks for long-running services. Use UI plugins, pollers, supervisor jobs, or OS services for that. |
| 14 | |
| 15 | ## hooks.json |
| 16 | |
| 17 | ```json |
| 18 | { |
| 19 | "hooks": [ |
| 20 | { |
| 21 | "name": "message-policy", |
| 22 | "command": "uv run python hook.py", |
| 23 | "events": ["pre_tool_call", "post_tool_call", "pre_prompt"], |
| 24 | "env": {}, |
| 25 | "timeout_seconds": 10, |
| 26 | "include_conversation": false |
| 27 | } |
| 28 | ] |
| 29 | } |
| 30 | ``` |
| 31 | |
| 32 | Valid events: |
| 33 | |
| 34 | - `pre_tool_call` |
| 35 | - `post_tool_call` |
| 36 | - `pre_prompt` |
| 37 | - `pre_startup` |
| 38 | - `post_startup` |
| 39 | - `pre_shutdown` |
| 40 | - `post_shutdown` |
| 41 | |
| 42 | There are no tool-name filters in `hooks.json`. Filter explicitly in the hook script: |
| 43 | |
| 44 | ```python |
| 45 | import json |
| 46 | import sys |
| 47 | |
| 48 | event = json.loads(sys.stdin.readline()) |
| 49 | if event.get("tool") != "send_message": |
| 50 | print(json.dumps(event)) |
| 51 | raise SystemExit |
| 52 | |
| 53 | event["args"]["text"] = event["args"]["text"].strip() |
| 54 | print(json.dumps(event)) |
| 55 | ``` |
| 56 | |
| 57 | ## Contract |
| 58 | |
| 59 | The hook process runs from the directory containing `hooks.json`. |
| 60 | |
| 61 | Environment: |
| 62 | |
| 63 | - `STATE_DIR`: the skill directory |
| 64 | - `HOOK_NAME`: the hook name |
| 65 | - `HOOK_EVENT`: the event type |
| 66 | |
| 67 | Input: |
| 68 | |
| 69 | - exactly one JSON object plus a newline on stdin |
| 70 | |
| 71 | Output: |
| 72 | |
| 73 | - stdout empty means no-op |
| 74 | - stdout containing a JSON object replaces the event for the next hook |
| 75 | - stderr is logged as `hook_stderr` |
| 76 | |
| 77 | For `pre_tool_call`, returning an event with an `args` object changes the tool args before execution. For `post_tool_call`, returning an event with `result` changes the result shown to the agent after a successful tool call. For `pre_prompt`, returning `prompt` replaces the prompt, and returning `append_prompt` appends text to the prompt. |
| 78 | |
| 79 | ### Blocking a tool call with `_block` |
| 80 | |
| 81 | A `pre_tool_call` hook may **reject the call entirely** by returning an event with an `_block` object: |
| 82 | |
| 83 | ```python |
| 84 | import json |
| 85 | import sys |
| 86 | |
| 87 | event = json.loads(sys.stdin.readline()) |
| 88 | if event.get("tool") == "send_message" and looks_bad(event["args"]): |
| 89 | event["_block"] = { |
| 90 | "reason": "shell-leak-prefix", |
| 91 | "result": "[hook blocked] send_message text starts with `$(...)` — that ships as a literal string, not a shell command. Read the file and inline the contents, or attach via attachment_paths.", |
| 92 | } |
| 93 | print(json.dumps(event)) |
| 94 | ``` |
| 95 | |
| 96 | When `_block` is set with both `reason` (string) and `result` (string), the wrapped tool is **not invoked**. The agent receives `result` as if it were the tool's own return value — so the agent sees the diagnostic next turn and can self-correct. A `hook_blocked_tool_call` event is logged, and post_tool_call hooks see `status="blocked"` with the same synthetic result. |
| 97 | |
| 98 | This is the right verb when a hook needs to **prevent** a bad call rather than mutate it. Args-mutation is fine when the call should still happen but with different inputs; `_block` is for "this call must not happen — tell the agent why." |
| 99 | |
| 100 | Invalid `_block` specs (missing fields, wrong types) are logged via `hook_invalid_block` and the call proceeds normally. This guards against silent drops from malformed hook output. |
| 101 | |
| 102 | Set `include_conversation: true` only for hooks that need transcript context. The hook event then includes: |
| 103 | |
| 104 | - `conversation.all_messages` |
| 105 | - `conversation.channel_messages` |
| 106 | - `conversation.current_channel_id` |
| 107 | |
| 108 | When `logs/chat-history.jsonl` exists, this is loaded from the persisted chat transcript. This is intentionally opt-in because full transcripts can be large. |
| 109 | |
| 110 | ## Reload |
| 111 | |
| 112 | After creating or editing `hooks.json`, call: |
| 113 | |
| 114 | ```text |
| 115 | reload_hooks() |
| 116 | ``` |
| 117 | |
| 118 | ## Debugging |
| 119 | |
| 120 | Check `logs/events.jsonl` for: |
| 121 | |
| 122 | - `hook_invalid_json` |
| 123 | - `hook_invalid_format` |
| 124 | - `hook_missing_fields` |
| 125 | - `hook_timeout` |
| 126 | - `hook_exec_error` |
| 127 | - `hook_stderr` |
| 128 | - `hook_nonzero_exit` |
| 129 | - `hook_blocked_tool_call` |
| 130 | - `hook_invalid_block` |
| 131 | - `hook_invalid_mutation` |
| 132 | - `hook_invalid_output` |
| 133 | - `hook_invalid_mutation` |
| 134 | |
| 135 | Keep hooks small and deterministic. Short scripts are easier to reason about than daemons hiding in the tool path. |