> ## Documentation Index
> Fetch the complete documentation index at: https://docs.poolside.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Hooks

> Run shell commands at agent lifecycle events to inspect or change agent activity.

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.

<Info>
  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](https://github.com/poolsideai/pool/releases) for release history.
</Info>

## 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](#security) before you enable hooks.

<Warning>
  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](/permissions) and [sandboxes](/sandboxes) for enforced controls.
</Warning>

## 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](#configuration-reference) for how `pool` runs Windows hooks.

**Steps**

1. Create `<project-path>/.poolside/hooks/block-hook-test.sh` with this content:

   ```sh title=".poolside/hooks/block-hook-test.sh" theme={null}
   #!/bin/sh
   input=$(cat)
   cmd=$(printf '%s' "$input" | jq -r '.tool_input.cmd // empty' 2>/dev/null) || cmd=""
   case "$cmd" in
     *"echo hook-test"*)
       echo "The hook test command was blocked (blocked by block-hook-test hook)" >&2
       exit 2
       ;;
   esac
   exit 0
   ```

2. Make the script executable:

   ```bash theme={null}
   chmod +x <project-path>/.poolside/hooks/block-hook-test.sh
   ```

3. Register the hook in `<project-path>/.poolside/settings.yaml`. Create the file if it does not exist:

   ```yaml title=".poolside/settings.yaml" theme={null}
   hooks:
     PreToolUse:
       - name: block-hook-test
         matcher: "shell"
         command: "<project-path>/.poolside/hooks/block-hook-test.sh"
         timeout: 10
   ```

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](#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

| Event              | When it fires                                                                    | A hook can                                            | Matcher                      |
| ------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------- |
| `PreToolUse`       | Before a tool executes, after argument validation and before the approval prompt | Block the call, or rewrite its arguments              | Tested against the tool name |
| `PostToolUse`      | As a tool result is recorded                                                     | Rewrite the tool output the model reads               | Tested against the tool name |
| `UserPromptSubmit` | When your prompt arrives, before the agent processes it                          | Block the prompt, rewrite it, or inject context       | Ignored                      |
| `Stop`             | When a turn is about to end, except when you cancel it                           | Ask the agent to keep working, with follow-up context | Ignored                      |
| `PreCompact`       | When context compaction is certain to run                                        | Inject context that survives compaction               | Ignored                      |
| `SessionStart`     | When a session starts or resumes                                                 | Inject context                                        | Ignored                      |

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

| Exit code     | Meaning                                                                                                                                                                         |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0`           | Success. Non-empty `stdout` is parsed as a decision object. Empty `stdout` means observe only.                                                                                  |
| `2`           | Block, with `stderr` as the reason. On `Stop`, this asks the agent to keep working instead, with `stderr` as the follow-up instruction. Events that cannot block ignore exit 2. |
| Anything else | The hook failed. The event proceeds as if the hook were absent, and the model is told the hook failed.                                                                          |

### Input payload

`pool` writes one JSON object to the hook's `stdin`:

```json title="Example: PreToolUse payload" theme={null}
{
  "hook_api_version": "1.0",
  "hook_event_name": "PreToolUse",
  "event_id": "evt-42",
  "session_id": "123e4567-e89b-12d3-a456-426614174000",
  "cwd": "/path/to/workspace",
  "trajectory_path": "/path/to/trajectory.ndjson",
  "tool_name": "shell",
  "tool_input": { "cmd": "sudo make install" },
  "tool_call_id": "call_abc123"
}
```

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:

| Event              | Additional fields                                        |
| ------------------ | -------------------------------------------------------- |
| `PreToolUse`       | `tool_name`, `tool_input`, `tool_call_id`                |
| `PostToolUse`      | `tool_name`, `tool_input`, `tool_output`, `tool_call_id` |
| `UserPromptSubmit` | `prompt`, which reflects any earlier hook's rewrite      |
| `Stop`             | `reason`, describing why the turn ended                  |
| `PreCompact`       | `trigger`, either `auto` or `forced`                     |
| `SessionStart`     | `source`, either `startup` or `resume`                   |

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:

```json title="Decision fields" theme={null}
{
  "decision": "block",
  "reason": "why",
  "continue": true,
  "hook_specific_output": {
    "permission_decision": "deny",
    "permission_decision_reason": "why",
    "updated_input": { "cmd": "safer command" },
    "updated_tool_output": "redacted text",
    "updated_prompt": "rewritten prompt",
    "additional_context": "context to inject"
  }
}
```

`decision: "block"` with a `reason` works on any event that can be blocked. The remaining fields apply per event:

| Event              | Fields it honors                                                                                                                                                              |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PreToolUse`       | `hook_specific_output.permission_decision: "deny"` with `permission_decision_reason`, the conventional way to deny; `hook_specific_output.updated_input` to rewrite arguments |
| `PostToolUse`      | `hook_specific_output.updated_tool_output`                                                                                                                                    |
| `UserPromptSubmit` | `hook_specific_output.updated_prompt`, `hook_specific_output.additional_context`                                                                                              |
| `Stop`             | `continue: true`, `hook_specific_output.additional_context`                                                                                                                   |
| `PreCompact`       | `hook_specific_output.additional_context`                                                                                                                                     |
| `SessionStart`     | `hook_specific_output.additional_context`                                                                                                                                     |

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](/permissions), and the most commonly guarded tools take these arguments:

| Tool         | `tool_input` keys                                 |
| ------------ | ------------------------------------------------- |
| `shell`      | `cmd`, `cwd`, `env`, `mode`, `shell_id`           |
| `read`       | `path`, `start_line`, `end_line`                  |
| `edit`       | `path`, `old_string`, `new_string`, `replace_all` |
| `write`      | `path`, `contents`                                |
| `remove`     | `path`                                            |
| `web_fetch`  | `url`                                             |
| `web_search` | `query`                                           |

To see the exact payload for any other tool, log one event and read it, as in [Inspect a payload](#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](#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](/cli/interactive-mode#get-debug-logs) and [View the trajectory](/cli/interactive-mode#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](#configure-your-first-hook).

### Block a privileged command

A `PreToolUse` hook with `matcher: "shell"` that blocks any shell command containing `sudo`:

```sh title=".poolside/hooks/block-sudo.sh" theme={null}
#!/bin/sh
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.cmd // empty' 2>/dev/null) || cmd=""
case "$cmd" in
  *sudo*)
    echo "sudo is forbidden in this workspace (blocked by block-sudo hook)" >&2
    exit 2
    ;;
esac
exit 0
```

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:

```sh title=".poolside/hooks/redact.sh" theme={null}
#!/bin/sh
input=$(cat)
out=$(printf '%s' "$input" | jq -r '.tool_output // empty' 2>/dev/null) || out=""
case "$out" in
  *sudo*)
    printf '%s' "$input" | jq -c '{hook_specific_output: {updated_tool_output: (((.tool_output // "") | gsub("sudo"; "[sudo-redacted]")) + "\n-- reviewed by redact hook")}}'
    ;;
  *)
    exit 0
    ;;
esac
```

### 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:

```sh title=".poolside/hooks/drive-once.sh" theme={null}
#!/bin/sh
MARKER="<project-path>/.hook-continued"
if [ -f "$MARKER" ]; then
  exit 0
fi
touch "$MARKER" 2>/dev/null || true
jq -n '{continue: true, hook_specific_output: {additional_context: "You stopped early. Please also write a one-line summary to SUMMARY.md, then stop."}}'
```

### 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:

```sh title=".poolside/hooks/log-payload.sh" theme={null}
#!/bin/sh
cat > /tmp/pool-hook-payload.json
```

### Inject context at session start

A `SessionStart` hook that announces a workspace policy:

```sh title=".poolside/hooks/announce-policy.sh" theme={null}
#!/bin/sh
input=$(cat)
src=$(printf '%s' "$input" | jq -r '.source // "unknown"' 2>/dev/null) || src="unknown"
jq -n --arg s "$src" '{hook_specific_output: {additional_context: ("Workspace policy (source=" + $s + "): sudo is forbidden in this workspace; hooks are active.")}}'
```

## Related resources

* [Poolside Agent CLI](/cli/pool)
* [Interactive mode](/cli/interactive-mode)
* [Settings file reference](/settings-file-reference)
* [Permissions](/permissions)
* [Sandboxes](/sandboxes)
