Hooks
A hook is a shell command that Tatu runs at a set point: before or after a tool call, when a session starts, when you send a message, when the model finishes. The command gets a JSON description of what is happening on its standard input. It answers with its exit code and, if it wants, JSON on its standard output.
This hook makes every bash command go through RTK, a tool that shortens the output of commands. Put it in ~/.tatu/settings.json:
{ "hooks": { "PreToolUse": [ { "matcher": "bash", "hooks": [ { "type": "command", "command": "rtk hook claude", "timeout": 10 } ] } ] }}With it, when the model asks to run git status, the command that runs is rtk git status. RTK reads the JSON on its input and answers with the rewritten command, in the format described below.
Where hooks are set
Section titled “Where hooks are set”Hooks live in the hooks setting, in your global file ~/.tatu/settings.json and in the project’s .tatu/settings.json. Unlike other settings, hooks add up: Tatu runs the global hooks first and then the project’s. Run /hooks to list the hooks in force, one line each, with the matcher, the command, whether it is global or from the project, and its timeout.
The shape is the event, then a list of groups, then the hooks of each group. A group has:
| Field | What it does |
|---|---|
matcher |
a regular expression that the whole tool name must match, such as "bash" or "edit|write". Empty or "*" matches every tool. Names are case-sensitive and are Tatu’s own: bash, read, write, edit, grep, find, agent and so on. A tool of an MCP server is mcp__<server>__<tool>, so "mcp__github__.*" matches every tool of the server github. |
hooks |
the list of hooks to run when the matcher fits. Each one has type, command and, optionally, timeout. |
And each hook has:
| Field | What it does |
|---|---|
type |
always "command". It is the only kind. |
command |
what to run. It runs with your shell, in the project’s folder. |
timeout |
seconds before Tatu stops the command. Default 60. |
The command also gets two environment variables: TATU_PROJECT_DIR (the project’s folder) and TATU_SESSION_ID.
Tatu checks the hooks when it starts. An event it does not know, a type other than command, an empty command, a matcher that is not a valid expression or any other unknown key is an error that stops Tatu from starting, with the file named.
The events
Section titled “The events”| Event | When it runs | What the input adds |
|---|---|---|
PreToolUse |
before a tool call | tool_name, tool_input, tool_use_id |
PostToolUse |
after a tool call | the same, and tool_response (content, is_error) |
SessionStart |
when Tatu starts, and on /new, /resume and /fork |
source: startup, resume, new or fork |
UserPromptSubmit |
when a message you wrote is about to join the conversation | prompt |
Stop |
when the model has finished its answer | stop_hook_active, last_assistant_message |
Every input also has session_id, cwd and hook_event_name. For example, a PreToolUse input looks like this:
{ "session_id": "dd982ee9", "cwd": "/home/ana/project", "hook_event_name": "PreToolUse", "tool_name": "bash", "tool_input": { "command": "git status", "timeout": 5 }, "tool_use_id": "toolu_01"}tool_input holds the arguments of the call. In PostToolUse, the text in content is cut at 32 KiB.
A matcher on SessionStart is tested against source, so "startup|resume" runs only when Tatu starts or resumes. UserPromptSubmit and Stop have no matcher: leave it out. Anything else is an error.
What a hook answers
Section titled “What a hook answers”The exit code and the output decide what happens.
PreToolUse
| The hook | What happens |
|---|---|
| exits 0 and prints nothing | the call goes on |
| exits 2 | the call is blocked, and what the hook wrote to its error output is the reason the model reads. With no text, the reason is blocked by a hook. |
| exits 0 and prints JSON | the JSON decides, as in the table below |
exits with another code, times out, or prints text that starts with { and is not valid JSON |
a warning appears in the conversation, and the call goes on without that hook |
PostToolUse: the call already ran. Exit 2 adds the hook’s error output to the result the model reads. JSON can add context. Other exit codes are a warning.
SessionStart: what the hook prints, as plain text or as additionalContext, becomes context for the model. It stays in the system prompt, under # Context from hooks, for as long as the conversation lasts. Exit 2 is only a warning here, since there is nothing to block. The hook runs before anything is shown, so keep it fast.
UserPromptSubmit: what the hook prints is added to your message for the model, after the text Context from a hook:. Exit 2, or JSON with "decision": "block", throws your message away: you see prompt blocked by a hook: <reason> and the model is not called. A notification about a background task is not a message of yours, so this hook does not see it.
Stop: exit 2 or "decision": "block" makes the model go on, told A Stop hook asked you to go on: <reason>. Tatu does this at most three times per run. Check stop_hook_active in the input to let the model stop.
For PreToolUse and PostToolUse, plain text on the output is ignored. To say something, print JSON:
| JSON field | Effect |
|---|---|
hookSpecificOutput.updatedInput |
PreToolUse: these keys replace the same keys of the call’s arguments, and the other keys stay. The call runs with the result. |
hookSpecificOutput.permissionDecision: "deny" |
PreToolUse: blocks the call. The reason is hookSpecificOutput.permissionDecisionReason. |
decision: "block" and reason |
PreToolUse: blocks the call. PostToolUse: adds the reason to the result. |
hookSpecificOutput.additionalContext |
text added to the result the model reads, as Note from a hook: .... |
Other fields are ignored.
When several hooks match, they run in order and each one sees the arguments the earlier ones left. A block ends the chain.
Two more examples
Section titled “Two more examples”This SessionStart hook tells the model which branch you are on whenever a session starts or resumes. Anything the command prints is context:
{ "hooks": { "SessionStart": [ { "matcher": "startup|resume", "hooks": [ { "type": "command", "command": "echo \"Current branch: $(git branch --show-current)\"" } ] } ] }}And this PreToolUse hook stops any command that contains rm -rf. It reads the input with jq:
{ "hooks": { "PreToolUse": [ { "matcher": "bash", "hooks": [ { "type": "command", "command": "jq -r .tool_input.command | grep -q 'rm -rf' && { echo 'rm -rf is not allowed here' >&2; exit 2; } || exit 0" } ] } ] }}The model then gets blocked by a hook: rm -rf is not allowed here as the result of the call.
Limits and cautions
Section titled “Limits and cautions”- A subagent’s tool calls go through the
PreToolUseandPostToolUsehooks. The other events belong to the main conversation. - Tatu writes a line to its log for each run, with the command and how it ended, never what it read or wrote.
- A hook that fails never stops your session. You see a warning such as
hook Stop (exit 1): exited with code 1and Tatu goes on. - Tatu keeps the first 64 KiB of what a hook prints.
- A hook runs on every matching call, so a slow one slows the session down.
See also
Section titled “See also”- Settings: the files that hold
hooks. - Coming from Claude Code: which Claude Code hooks work here.
- Tools of the model: the tool names a matcher uses.
- Security and trust: what Tatu can do on your machine.