Hooks
Hooks let you intercept and modify the chat pipeline — before a prompt reaches the model, after the model responds, or at tool-use boundaries. Common uses: redacting secrets from prompts, logging to a SIEM, or injecting context automatically.
Hooks run at named lifecycle events in the chat pipeline. The most common are pre_send (before a prompt is sent to the model) and post_send (after the model responds), but hooks can also fire at tool-use boundaries, session start, permission gates, and more. Use them for prompt rewriting, logging, response auditing, blocking patterns, or injecting context.
Hook kinds
| Kind | What it does |
|---|---|
| Builtin | Calls a Go function compiled into Kenaz. Curated list — fast, stable. |
| Shell | Runs an external command. Receives the event as JSON on stdin, can rewrite or block. |
| MCP | Calls a tool on a connected MCP server. Useful when the same server already has the logic you want. |
Setting up a hook
Hooks view → Add hook.
Each hook has:
- ID — internal identifier (auto-generated; you can rename).
- Name — human label shown in lists.
- Event — the lifecycle event to fire on. v1 chat-pipeline events:
pre_send,post_send,pre_save_session,post_assistant_turn_complete. v2 events includepre_tool_use,post_tool_use,user_prompt_submit,session_start,permission_request, and others. The full list is in Hooks view → Add hook → Event picker. - Kind — Builtin / Shell / MCP.
- Match — optional filter by session IDs, provider kinds, or model IDs. Leave empty to match all.
- Enabled — toggle without deleting.
Shell hooks — the contract
A shell hook receives the event JSON wrapped in {"input": <event>} on stdin and writes a JSON response on stdout. Stderr is logged as a warning but doesn't block.
Pre-send stdin:
{
"input": {
"session_id": "…",
"messages": [{"role": "user", "content": "the user's prompt"}],
"model": "…",
"kind": "pre_send"
}
}
Pre-send stdout (optional):
{
"continue": true,
"messages": [{"role": "user", "content": "rewritten prompt"}],
"stop_reason": ""
}
If your hook emits no output, or "continue" is absent or true, Kenaz uses the original event. Setting "continue": false stops processing and logs stop_reason. If "messages" is non-empty, it replaces the original messages array.
Post-send is symmetric — stdin carries {"input": {"session_id": ..., "user_turn": "...", "assistant_turn": "...", ...}} and stdout can return the same envelope (mutations to assistant_turn are not threaded back in v1).
Timeouts
Shell hooks have a default 10-second timeout. Override per-hook in the editor. If a hook times out, Kenaz logs a warning and proceeds with the original event — hooks can never block a turn entirely.
Example: redact a pattern
#!/usr/bin/env bash
# Redact AWS access key IDs from prompts before they reach the model.
jq '.input.messages |= map(.content |= gsub("AKIA[0-9A-Z]{16}"; "[REDACTED]"))'
Save as ~/bin/redact-aws-keys.sh, make executable, point a pre_send hook at it.
Builtin hooks
The harness ships two builtin hooks (installed via Hooks view → Install starter memory hooks or by toggling memory on in the Tools view):
memory.retrieve(pre_send) — runs a k-NN search against stored memory chunks and prepends the top results as a system message before each turn.memory.persist(post_send) — embeds the user+assistant turn and writes a memory chunk to the local store after each turn.
These are the only builtins in v1. Check Hooks view → Add hook → Builtin in the app for the current list as new builtins are added in future releases.
Per-match hooks
Use the Match filter to keep hooks focused:
- Per-session — set
session_idsto a specific session ID. - Per-model — set
modelsto the model identifier (e.g.claude-opus-4-5). - Per-provider-kind — set
kindsto the provider kind string.
Leave all match fields empty to match every invocation. When multiple hooks match the same event, they run in registration order; each sees the output of the previous one.
Failure modes
- A hook that errors (non-zero exit, malformed JSON, timeout) is logged and skipped. The model still sees the prompt and the user still sees the response. Hooks can't break a session.
- A hook that hangs gets killed at the timeout. The kill is graceful (SIGTERM, then SIGKILL after a grace window) so transient subprocesses don't leak.
Audit
Every hook invocation writes to the audit log: kind = "hook.fired", with the hook ID, scope, exit status, and duration. Useful when you want to verify a redaction hook actually ran on the turn you care about.