Skip to content
titah

Automation

Hooks

Shell commands at the hook points plugins already use — for the rules that are one line long and do not deserve an npm package.

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" }]
  }
}
KeyMeaning
matchRegex over the tool name. Omit it to match every tool
runThe shell command
timeoutMilliseconds. 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:

VariableHolds
TITAH_TOOLThe tool name
TITAH_SESSIONThe session id
TITAH_CWDThe 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.