Skip to content

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.

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.

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.

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.

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.

  • A subagent’s tool calls go through the PreToolUse and PostToolUse hooks. 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 1 and 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.