Skip to content

hooks

Claude Code-style hooks that intercept any point in the agent lifecycle. Three hook types:

  • Command: run a shell command. Event data passed via PI_HOOK_EVENT env var. Exit 0 = allow, exit 2 = block (stderr becomes the reason).
  • Prompt: single LLM call for evaluation (not yet implemented).
  • Agent: spawn a subagent with tool access and a prompt template. Use $ARGUMENTS for event data injection.

Hooks can be sync (blocking, can return accept/reject decisions) or async (fire-and-forget background tasks). Matchers filter which tools or events trigger each hook using regex patterns.

Subprocess safety defaults:

  • command/agent hook subprocess output is bounded independently for stdout and stderr (1 MiB each)
  • output beyond the cap is truncated with an [output truncated] marker
  • timeout/abort termination escalates deterministically: SIGTERM → grace period → SIGKILL
  • agent hooks resolve their runner in this order: TALLOW_HOOK_AGENT_RUNNER override → current tallow executable (when detectable) → tallow on PATH → legacy pi fallback

Configure in hooks.json at the project root or in settings. Events prefixed with before_ or session_before_ support blocking (can cancel the operation). tool_call and input also support blocking. All other events are observe-only.

Every event you can hook into:

EventDescriptionMatcher fieldCan block?
tool_callBefore a tool executestoolNameYes
tool_resultAfter a tool returnstoolNameNo
user_bashWhen user runs ! or !! commandscommandNo
EventDescriptionMatcher fieldCan block?
before_agent_startAfter user submits, before agent loop startsNo
agent_startWhen the agent starts processingNo
agent_endWhen the agent finishes processingNo
turn_startAt the start of each agent turnNo
turn_endAt the end of each agent turnNo
inputWhen the user submits inputYes
EventDescriptionMatcher fieldCan block?
subagent_startWhen a subagent spawnsagent_typeNo
subagent_stopWhen a subagent completesagent_typeNo
EventDescriptionMatcher fieldCan block?
worktree_createWhen a managed worktree is createdscopeNo
worktree_removeWhen a managed worktree is removedscopeNo

Claude-compatible aliases are also supported in translated .claude hooks:

  • WorktreeCreateworktree_create
  • WorktreeRemoveworktree_remove

Worktree payloads include worktreePath, repoRoot, scope, timestamp, and optional agentId for subagent-scoped events.

EventDescriptionMatcher fieldCan block?
session_startWhen a session starts or resumesNo
session_shutdownWhen a session is shutting downNo
session_before_compactBefore context compactionYes
session_compactAfter context is compactedNo
session_before_switchBefore switching sessionsYes
session_switchAfter switching sessionsNo
session_before_forkBefore a session forkYes
session_forkAfter a session forkNo
session_before_treeBefore session tree navigationYes
session_treeAfter session tree navigationNo
EventDescriptionMatcher fieldCan block?
contextBefore each LLM call with message contextNo
model_selectWhen the model changes (set, cycle, restore)sourceNo
notificationWhen a notification is emitted via event bustypeNo
teammate_idleWhen a team teammate becomes idleteammateNo
task_completedWhen a team task completesassigneeNo
setupBefore session starts (via --init / --maintenance)triggerNo

Add "once": true to any hook handler to run it exactly once. The hook executes on the first matching event, then auto-disables itself. State persists to ~/.tallow/hooks-state.json so it survives session restarts.

Use cases: welcome messages, first-time setup, one-shot migration checks.

{
"session_start": [
{
"hooks": [
{
"type": "command",
"command": "echo '{\"ok\": true, \"additionalContext\": \"👋 Welcome!\"}'",
"once": true,
"timeout": 5
}
]
}
]
}

For sync hooks, state is recorded after successful execution (ok: true), so a failing once-hook retries on the next event. For async hooks, state is recorded immediately before execution to prevent race conditions from duplicate events.

Hooks are loaded from all sources and merged additively at session start. Matchers are concatenated per event, nothing is replaced. Every source participates:

  1. hooks.json from local paths in ~/.tallow/settings.json packages (lowest priority)
  2. ~/.tallow/hooks.json (global standalone)
  3. ~/.tallow/settings.jsonhooks key (global settings)
  4. .tallow/hooks.json (project standalone, trusted projects only)
  5. .tallow/settings.jsonhooks key (project settings, trusted projects only)
  6. ~/.tallow/extensions/*/hooks.json (global extension hooks)
  7. .tallow/extensions/*/hooks.json (project extension hooks, trusted projects only)
  8. .claude/settings.jsonhooks key (project Claude hooks, translated)
  9. ~/.claude/settings.jsonhooks key (global Claude hooks, translated)
  10. Runtime: hooks:merge event bus (other extensions)

When a project is untrusted (or trust is stale), project .tallow hook sources are blocked. Trust with /trust-project, inspect with /trust-status, and revoke with /untrust-project.

This means any extension or package can ship a hooks.json alongside its code and it automatically participates in the hook system. A hook from a package, a global extension, and your project’s hooks.json all fire on the same event if their matchers match.

tallow ships one basic starter hook: a tool_call command hook that matches bash invocations. It’s intentionally minimal, just enough to demonstrate the pattern.

{
"hooks": {
"tool_call": [
{
"matcher": "bash",
"hooks": [
{
"type": "command",
"command": "~/.tallow/extensions/hooks/tool-call/tool-call.sh",
"timeout": 5
}
]
}
]
}
}

The pattern is straightforward. Here are a few examples to start from:

Spawn a reviewer agent asynchronously after every write or edit. The agent checks the change and queues its result for the next turn.

{
"tool_result": [
{
"matcher": "write|edit",
"hooks": [
{
"type": "agent",
"agent": "reviewer",
"prompt": "Review this file change for issues:\n\n$ARGUMENTS",
"timeout": 60,
"async": true
}
]
}
]
}

A sync command hook that inspects the bash input before execution. The shell script exits 2 to block, with the reason on stderr.

{
"tool_call": [
{
"matcher": "bash",
"hooks": [
{
"type": "command",
"command": "~/.tallow/hooks/check-destructive.sh",
"timeout": 5
}
]
}
]
}
check-destructive.sh
#!/bin/bash
# PI_HOOK_EVENT contains JSON with the tool call details
INPUT=$(echo "$PI_HOOK_EVENT" | jq -r '.input.command // empty')
if echo "$INPUT" | grep -qE 'rm\s+-rf\s+/'; then
echo "Blocked: refusing to rm -rf a root path" >&2
exit 2
fi
exit 0

After the agent finishes, spawn a reviewer to verify all tasks were completed.

{
"agent_end": [
{
"hooks": [
{
"type": "agent",
"agent": "reviewer",
"prompt": "Check if all tasks were completed. Return { \"ok\": true } or { \"ok\": false, \"reason\": \"...\" }.\n\n$ARGUMENTS",
"async": true
}
]
}
]
}

Log when subagents start and stop. The matcher filters on agent_type (the agent name).

{
"subagent_start": [
{
"hooks": [
{
"type": "command",
"command": "echo \"Subagent started: $(echo $PI_HOOK_EVENT | jq -r .agent_type)\" >> /tmp/subagent.log",
"async": true
}
]
}
],
"subagent_stop": [
{
"hooks": [
{
"type": "command",
"command": "echo \"Subagent stopped: $(echo $PI_HOOK_EVENT | jq -r '.agent_type + \" exit=\" + (.exit_code|tostring)')\" >> /tmp/subagent.log",
"async": true
}
]
}
]
}

Block compaction unless the session has been running for a while.

{
"session_before_compact": [
{
"hooks": [
{
"type": "command",
"command": "echo $PI_HOOK_EVENT | jq -e '.tokensBefore > 50000' > /dev/null || (echo 'Too early to compact' >&2; exit 2)",
"timeout": 5
}
]
}
]
}

Track when the model switches, filtered to cycle events only.

{
"model_select": [
{
"matcher": "cycle",
"hooks": [
{
"type": "command",
"command": "echo \"Model cycled to: $(echo $PI_HOOK_EVENT | jq -r .modelName)\" >> /tmp/model.log",
"async": true
}
]
}
]
}