Titah has had tool.before and tool.after since plugins existed. Reaching
them meant building a JavaScript package, writing a factory and registering it:
a cost far larger than a rule like “run the formatter after every edit”.
Hooks are not a new hook point. They are a second way to attach to the same one, and a much cheaper way for something that really is one command.
{
"hooks": {
"tool.before": [{ "match": "^bash$", "run": "scripts/guard.sh" }],
"tool.after": [{ "match": "edit|write", "run": "npm run format" }]
}
}
| Key | Meaning |
|---|---|
match | Regex over the tool name. Omit it to match every tool |
run | The shell command |
timeout | Milliseconds. Default 30000 |
What the hook receives
The event arrives on stdin as JSON. For one-liners that do not want to parse anything, three environment variables are set:
| Variable | Holds |
|---|---|
TITAH_TOOL | The tool name |
TITAH_SESSION | The session id |
TITAH_CWD | The session working directory |
#!/usr/bin/env bash
# scripts/guard.sh — refuse any bash command that touches ~/.ssh
jq -e '.input.command | test("\\.ssh")' >/dev/null && {
echo "refusing: that command touches ~/.ssh" >&2
exit 1
}
exit 0
A hook is free to ignore stdin entirely. pwd, true, and a formatter that
only reads $TITAH_TOOL close stdin and exit, and the write that arrives after
that fails with EPIPE — which used to reach Node as an unhandled error event
and kill the process. It is ignored now: a hook’s verdict comes from its exit
code, so a payload the hook never wanted is nothing to report. The bug hid
because small payloads fit entirely in the pipe buffer; it only showed when the
child won the race, which a tool input carrying a whole file makes ordinary.
The two contracts are deliberately asymmetric
tool.before refuses the call on a non-zero exit, and its stderr becomes the
reason the model is given. A hook that fails to start, or hangs past its
timeout, also refuses — a guard that goes quiet when it breaks is worse than
no guard, because everyone downstream assumes it is still watching.
tool.after cancels nothing. The work already happened; there is nothing to
take back. But its stderr is appended to the tool output, so the model sees
it. A formatter that failed silently is a formatter everybody believes
succeeded.
Order
Plugins run first, then shell hooks — and the shell hooks see the input that plugins have already reshaped. See Plugins.
Checking them
titah hooks list
Prints the hooks from your config and which tools each one matches.
It names invalid regexes explicitly, which is the whole reason the command exists: a hook that is silent because its pattern is broken looks exactly like a hook that simply did not match anything, and the two are debugged completely differently.