Skip to main content
Use hooks to run your own shell commands at fixed points while the agent works. You can inspect activity, change what the model receives, or stop a matching action before it runs.
This documentation describes Poolside Agent CLI v1.0.16. Check your version with pool --version. To update, exit any active session and run pool update from your terminal. See Poolside Agent CLI releases on GitHub for release history.

What you can do with hooks

  • Add custom checks that block matching commands, such as sudo or force pushes.
  • Redact secrets or internal hostnames from tool output before the model reads it.
  • Screen prompts against custom policies, or add standing context to every prompt.
  • Announce workspace conventions at session start.
  • Keep the agent working until it completes a required outcome, such as creating a summary file.
After you configure a hook, it runs automatically on every matching event. You do not approve each invocation. Read Security before you enable hooks.
Hooks fail open and are not a security boundary. If a hook fails, times out, or returns output pool cannot parse, the event continues without it. Use permissions and sandboxes for enforced controls.

Configure your first hook

This example adds a hook that blocks a harmless test command, so you can verify the hook without risking a privileged action if the hook does not run. Prerequisites
  • You use a POSIX shell on macOS or Linux.
  • You have installed jq.
On Windows, create an equivalent script and use the same settings structure. See Configuration reference for how pool runs Windows hooks. Steps
  1. Create <project-path>/.poolside/hooks/block-hook-test.sh with this content:
    .poolside/hooks/block-hook-test.sh
  2. Make the script executable:
  3. Register the hook in <project-path>/.poolside/settings.yaml. Create the file if it does not exist:
    .poolside/settings.yaml
  4. Start a new agent session in the project.
  5. Ask the agent to run echo hook-test.
The command does not run. The agent receives the hook’s reason and can choose another approach. Test every hook against the condition it should handle. pool ignores decision fields it does not recognize, so a misspelled field can produce no error and have no effect.

How a hook runs

Every hook follows the same cycle:
  1. The event fires. pool reaches a lifecycle point, such as a tool that is about to run, and selects the hooks configured for that event. On tool events, only hooks whose matcher matches the tool name run.
  2. pool writes the event JSON to the hook’s stdin. One JSON object describes the event: which event it is, the session it belongs to, and the event’s data, such as the tool name and its arguments. Read it once, at the start of your script.
  3. Your script does its work and decides what should happen.
  4. Your script answers with an exit code, and optionally JSON on stdout. Exit 0 with empty stdout observes and changes nothing. Exit 0 with a JSON object asks for a specific decision, such as denying a call or rewriting its arguments. Exit 2 blocks the event, with the text your script wrote to stderr as the reason. See Exit codes for what blocking means at each event.
  5. pool applies the decision and continues, telling the model what changed. If several hooks are configured for the event, they run one after another, and each one sees the previous hook’s rewrite.
A hook that changes nothing produces no output anywhere. If you test a hook and see nothing, either it ran and chose not to act, or it never matched.

Hook events

Notes on specific events:
  • PreToolUse runs before the approval prompt. A blocked call is never offered for approval, and a rewritten call is approved on its rewritten arguments rather than the ones the model proposed. A block does not end the turn: the model reads the block reason as the tool result and can try something else.
  • A UserPromptSubmit block returns an error to you, and the model never sees the prompt.
  • Stop hooks do not run when you cancel a turn yourself, so a hook can never override your stop.
  • A Stop continuation arrives as a new input in the session, as if you had sent it yourself. It does not pass through UserPromptSubmit hooks, so hooks cannot feed into each other. Continuations do not reset or extend the session’s step budget; they keep drawing on the same max_steps.

Write additional hook scripts

  • Capture stdin first with input=$(cat). You can read stdin a single time. Watch out for shell here-documents: with python3 <<'EOF', the here-document becomes the child process’s stdin and hides the event JSON. Use python3 -c '<code>' instead.
  • Write block reasons to stderr, not stdout.
  • Print to stdout only when you mean it. Any non-empty stdout on exit 0 is parsed as a decision, and output that does not parse marks the hook as failed.
  • Keep scripts fast. Set a tight timeout when the default 60 seconds is more than you need.

Protocol reference

All field names use snake_case, in both directions. hook_api_version is currently 1.0. Changes within a major version are additive only.

Exit codes

Input payload

pool writes one JSON object to the hook’s stdin:
Example: PreToolUse payload
Every event carries the envelope fields: hook_api_version, hook_event_name, and event_id, which is opaque and shared by all hooks handling the same event occurrence, plus session_id, cwd, and trajectory_path when they are known. Your hook can use these to find session state on its own. pool never streams trajectory content to a hook’s stdin. Each event adds its own fields: The PreToolUse and PostToolUse events for one call share tool_call_id, not event_id.

Output decision

On exit 0, non-empty stdout is parsed as a decision object. All fields are optional:
Decision fields
decision: "block" with a reason works on any event that can be blocked. The remaining fields apply per event: A block or continue decision from a PreCompact or SessionStart hook is ignored, and the model is told. Use the decision field names exactly as shown. pool ignores fields it does not recognize, so well-formed JSON with a misspelled field can produce no error and have no effect.

How hooks combine

  • Hooks for an event run sequentially in configuration order, never in parallel. Rewrites thread from hook to hook: each hook sees the previous hook’s validated rewrite.
  • If any hook denies, the event is blocked. Deny reasons combine in configuration order. Later hooks still run, but their rewrites are not applied after a deny. Injected context still accumulates.
  • A hook that times out, cannot start, exits with an unexpected code, or prints invalid JSON is skipped: the event proceeds without it and the model is told the hook failed. A failed hook never blocks anything.
  • A PreToolUse rewrite that fails the tool’s argument validation blocks the call. The tool does not run with the model’s original arguments, and the model is told why.
  • Rewritten or injected text must be valid UTF-8. Invalid text is rejected, the original is kept, and the model is told.

Configuration reference

Define hooks under a hooks: key in settings.yaml, at any of these levels:
  1. Personal defaults: ~/.config/poolside/settings.yaml
  2. Shared, project-specific: <project-path>/.poolside/settings.yaml
  3. Personal, project-specific: <project-path>/.poolside/settings.local.yaml
  4. The --settings flag, available on pool acp only, as the most specific level
For each event, the lists from all levels combine in that order, so less specific hooks run first. A named hook declared again at a more specific level replaces the earlier declaration in its original position. Unnamed hooks always combine. Unknown event names are skipped with a warning. Each hook entry supports these fields:
  • command: Required. Runs through /bin/sh -c on Unix or cmd /c on Windows.
  • matcher: Provide this field for every event. It selects tools on PreToolUse and PostToolUse. Other events ignore it, so use matcher: "*". Matcher forms:
    • "" or "*": Any tool
    • A bare name such as shell: Exact match
    • shell|bash: Pipe-separated list of exact names
    • Anything else: A regular expression. An invalid pattern disables the hook with a warning when settings load.
  • name: Optional. It labels the hook in logs and in what the model sees, and it identifies the hook when settings levels combine, so a more specific level can replace it. Without a name, the label falls back to the command’s filename.
  • timeout: Optional per-hook limit in seconds. The default is 60. On timeout, the hook’s whole process group is killed.

Tool names and arguments

A matcher selects a tool by name, and your script then reads fields of tool_input, which holds exactly the arguments the model passed to that tool. If your script reads a key that the tool does not have, the hook silently does nothing, so check the key names first. Tool names are the ones you use in the tools: section of Permissions, and the most commonly guarded tools take these arguments: To see the exact payload for any other tool, log one event and read it, as in Inspect a payload.

Limit Stop hook continuations

Set stop_hook_max_continuations inside the hooks: section, alongside the event names, to cap how many times in a row Stop hooks can make the agent continue. When it is unset, continuations are unlimited, so set it whenever a Stop hook can return continue: true. Setting it to 0 refuses every continuation, and negative values are rejected by the settings schema. The counter resets each time you send a real prompt, and the most specific settings level that sets a value wins. For a Stop hook that pairs with this cap, see Keep a turn going one time.

What you and the model see

Silence is the default. A hook that changes nothing leaves no trace in the conversation. Rewrites, blocks, continuations, and failures add a <hook> tag or reminder for the model that names the hook and event. Hook notices and reminders do not appear as standalone messages in the terminal UI. They are recorded in the session trajectory and debug log. See Get debug logs and View the trajectory. Behavior worth knowing when you review a session:
  • When a PreToolUse hook rewrites arguments, the conversation and trajectory keep the model’s original arguments. The arguments the tool actually ran with appear inside the hook tag.
  • When a PostToolUse hook rewrites output, the rewritten text is the tool result, with a tag naming the hook appended to it.
  • The prompt for a Stop continuation is the hooks’ additional_context, joined with newlines when several hooks contribute, or a default line naming the hook when none provide it.
  • Context injected by a PreCompact hook is added after compaction runs, so it survives compaction verbatim.

Limits

  • Hook text shown to the model is capped at 32 KiB and truncated with a marker beyond that. Prompt rewrites are not capped.
  • A hook can print up to 1 MiB on stdout. More than that fails the hook.
  • Block reasons on stderr are kept up to 64 KiB.

Security

Hook commands run wherever pool runs, with the same user permissions and no additional sandboxing, automatically on every matching event. There is no approval prompt for hook execution. Hooks also fail open: a hook failure does not stop the event. Treat the hooks: section like a shell startup file such as .bashrc:
  • Review the hooks: section and every referenced script before you run pool in a cloned repository. A repository’s .poolside/settings.yaml can define hooks.
  • Hooks receive tool arguments, tool output, and your prompts on stdin, and a hook can do anything your shell can, including sending that data over the network.
  • If you ask the agent to write a hook for you, review the script and the settings change before enabling them.

Examples

These additional examples use jq and a POSIX shell. Save each script in .poolside/hooks/, make it executable with chmod +x, and register it under the matching event in settings.yaml with the absolute path to the script, as shown in Configure your first hook.

Block a privileged command

A PreToolUse hook with matcher: "shell" that blocks any shell command containing sudo:
.poolside/hooks/block-sudo.sh
The sudo command is blocked before it runs and before any approval prompt appears. The model reads the stderr message as the tool result and can try a different approach. Any other shell command passes through untouched.

Redact tool output

A PostToolUse hook with matcher: "shell" that rewrites the tool output the model reads. It stays silent when there is nothing to redact:
.poolside/hooks/redact.sh

Keep a turn going one time

A Stop hook that asks the agent to keep working exactly one time, using a marker file. Pair it with stop_hook_max_continuations as a second guard:
.poolside/hooks/drive-once.sh

Inspect a payload

Any hook that writes the event JSON to a file, so you can read the exact keys a tool sends. Register it on the event you want to inspect:
.poolside/hooks/log-payload.sh

Inject context at session start

A SessionStart hook that announces a workspace policy:
.poolside/hooks/announce-policy.sh