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

# Plugin hooks

> Hooks that Agent Plugins ship, how you trust them, and the contract Xum gives hook authors

<Note>Plugin hooks are experimental. Expect breaking changes while we iterate.</Note>

An [Agent Plugin](/agents/agent-skills#agent-plugins) can ship hooks in two formats:

| Format | File | Runs as | Trust step |
| - | - | - | - |
| Codex format | `hooks/hooks.json` | A call to a tool on the plugin's own MCP server | You trust each hook in Settings |
| Xum-native | `hooks.js` in the plugin root | JavaScript in a QuickJS sandbox | Project Trust for project plugins |

For hooks in your own repository, use [tool hooks](/hooks/tools) instead.

## Codex-format hooks

Xum reads the [Codex CLI hooks format](https://learn.chatgpt.com/docs/hooks), so a Codex plugin's `hooks.json` works without edits.

### Where they come from

Xum reads `hooks/hooks.json` in the plugin. If `plugin.json` has `extensions["com.openai"].hooks`, that list replaces the default file. Each entry is a relative path to a hooks file or an inline hooks object. An empty list turns the default file off.

Only managed plugins run these hooks. A managed plugin is one you installed from **Settings → Plugins** into `~/.xum/plugins`. Project plugins and plugins in `~/.agents/plugins` never run them.

Xum also runs no Codex-format hook in these cases:

* The project is not trusted.
* The workspace is not a local or worktree workspace, for example an SSH, Docker, devcontainer, or Coder workspace.
* The workspace is a multi-project workspace.
* The chat is a `/side` chat.

### Trust a hook

Installing a plugin trusts none of its hooks. The install preview lists them.

1. Open **Settings → Plugins** and select **Hooks** on the plugin. You can also run **Review Plugin Hooks…** from the command palette.
2. Read each hook. If Xum cut a long `input`, select **Show full input** first. **Trust** stays off until you do.
3. Select **Trust**. Select **Turn off** to stop a hook.

Xum trusts the exact definition of a hook: its event, its matcher, and its handler. Reformatting the file keeps trust. Trust lapses in these cases:

* The definition changes. The hook shows **changed, needs review** and does not run until you trust it again.
* The hook moves to another place in its file, for example to another group. It then shows **needs review**.
* You turn the hook off.
* You uninstall the plugin. A reinstall under the same name starts with no trusted hooks.

Xum checks trust before each hook call, not once per turn. A revoke stops the next call. A newly trusted hook starts to run on your next message.

### Events and handlers

| Codex event | When Xum runs it |
| - | - |
| `PreToolUse` | Before a tool call |
| `PostToolUse` | After a tool call ran |
| `UserPromptSubmit` | Before Xum sends your message |
| `Stop` | When a turn finishes normally |

`Stop` does not run after an interrupted or failed turn, a compaction, a sub-agent turn, or a delegated workspace turn. It also does not run when a message or a wake is already queued when the turn ends, because the turn continues with that input.

Xum lists the other Codex events (`PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, `SessionStart`, `SessionEnd`, `Interrupt`) in Settings as "Never runs".

Xum runs only `mcp_tool` handlers. Such a handler calls `tool` on the plugin's own MCP server `server`. That server must be enabled and running in the workspace, or the hook does not run. Xum lists `command`, `prompt`, and `agent` handlers as "Never runs".

```json theme={null}
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "guard",
            "tool": "check_command",
            "input": { "command": "${tool_input.command}", "cwd": "${cwd}" },
            "timeout": 5
          }
        ]
      }
    ]
  }
}
```

Xum fills `${field.path}` placeholders in `input` from the event fields below. A placeholder that is the whole string keeps its JSON type. A placeholder inside a longer string becomes text.

Within one plugin, matching hooks run one after another in file order. Plugins run in discovery order. `PostToolUse` runs in reverse plugin order. Codex-format hooks run before the [tool hooks](/hooks/tools) `.xum/tool_pre`, so `tool_pre` sees a rewritten input.

### Matchers

`UserPromptSubmit` and `Stop` ignore `matcher`. On tool events:

* `"*"`, `""`, `null`, or no matcher matches every tool.
* A list of names (letters, digits, `_`, and `|`, for example `Bash|Edit`) matches a whole name, not a substring.
* Any other value is a regex. Codex uses the Rust `regex` dialect. Xum compiles it as a JavaScript RegExp.
* A leading flag group of `i`, `m`, and `s` (for example `(?i)` or `(?is)`) becomes RegExp flags.
* Xum translates `(?P<name>...)` to a JavaScript named group, and the `\A` and `\z` anchors to lookarounds that mean the start and end of the name, as in Rust. A group name must follow Rust's rule and compile in JavaScript (letters, numbers, and `_`, at most 128 characters). A name used twice is refused.
* Xum refuses other Rust syntax that JavaScript reads in a different way or rejects. Examples are inline flags after the start, negated flags (`(?-i)`), the `x`, `U`, `u`, and `R` flags, `\pL`, `\p{...}`, `\x{...}`, `\b{start}`, `\<`, `\>`, nested or POSIX classes (`[[:alpha:]]`), and `&&`, `--`, or `~~` in a class.

Xum drops a group with a refused or invalid matcher and shows a warning in Settings. Xum tests the matcher against the Xum tool name and against the Codex name below.

### Timeouts

`timeout` is in whole seconds. The default is 10 seconds. Xum caps it at 30 seconds and raises `0` to 1 second. The Codex default of 600 seconds does not apply.

Before `UserPromptSubmit` hooks run, Xum waits for the workspace's MCP servers to start. The wait is at most the longest `UserPromptSubmit` timeout.

### Event fields

Every event has these fields:

| Field | Value |
| - | - |
| `hook_event_name` | `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, or `Stop` |
| `session_id` | The Xum workspace ID |
| `cwd` | The workspace directory. See the note below the table. |
| `permission_mode` | Always `bypassPermissions`. Xum asks for no tool approval. |
| `transcript_path` | Always `null`. Xum's chat log is not a Codex transcript. |

Tool events always have `cwd`. On `UserPromptSubmit` and `Stop`, Xum leaves `cwd` out when it cannot resolve the workspace directory. A `${cwd}` placeholder then becomes `null` (whole string) or empty text (inside a longer string).

Per-event fields:

| Event | Fields |
| - | - |
| `PreToolUse` | `tool_use_id`, `turn_id`, `model`, `tool_name`, `tool_input` |
| `PostToolUse` | The `PreToolUse` fields and `tool_response` |
| `UserPromptSubmit` | `prompt`. It has no `turn_id` and no `model`. |
| `Stop` | `turn_id` (the assistant message ID), `model`, `stop_hook_active`, `last_assistant_message` |

`model` is Xum's provider-qualified model string, for example `openai:gpt-5.2`. Codex sends the bare model name. A tool call outside a model turn has no `turn_id` and no `model`.

### Tool names and the Bash contract

| Xum tool | `tool_name` | `tool_input` | `tool_response` |
| - | - | - | - |
| `bash` | `Bash` | `{ command }` | The output text (a string) |
| An MCP tool | `mcp__<server>__<tool>` | The tool arguments | The tool result |
| Any other built-in tool | Its Xum name | Its Xum arguments | Its Xum result |

A hook that reads `tool_input.script` does not work. Read `tool_input.command`. For a background `bash` call, `tool_response` is the output so far.

In an MCP name, Xum replaces each character other than `[A-Za-z0-9_]` with `_`. For the plugin's own server, `<server>` is the server name in the plugin.

### Outputs Xum honors

The hook's MCP tool returns its output as JSON text. Xum joins the text blocks of the result. If the result has no text, Xum reads `structuredContent`.

| Event | Output | Effect |
| - | - | - |
| `PreToolUse` | `hookSpecificOutput.permissionDecision: "deny"`, or `decision: "block"` | Xum blocks the call. The model sees the reason as the tool error. |
| `PreToolUse` | `hookSpecificOutput.permissionDecision: "allow"` with `hookSpecificOutput.updatedInput` | Xum runs the call with the new input. |
| `PostToolUse` | `decision: "block"`, or `continue: false` | Xum replaces the tool result with the hook's feedback. |
| `UserPromptSubmit` | `decision: "block"` with `reason`, or `continue: false` | Xum does not send a message you wrote. See the note below the table. |
| `UserPromptSubmit` | Plain text that does not start with `{` or `[` | Xum adds the text as context. |
| `Stop` | `decision: "block"` with `reason` | The turn continues with `reason` as the next prompt, at most 3 times per message. See the note below the table. |
| `Stop` | `continue: false` | Xum ends the turn and shows `stopReason` to you. |
| All but `Stop` | `hookSpecificOutput.additionalContext` | Xum adds the text for the model. On tool events, it goes on the result as `hook_output`. |
| Any event | `systemMessage` | Xum shows the message to you. The model never sees it. |

A `UserPromptSubmit` block applies only to a message you send (typed, edited, queued, or plan-review feedback). Automatic sends (goal and task continuations, wakes, follow-ups) ignore every block, because their sender would retry the same message in a loop. They keep the context of the other hooks. A hook whose output both blocks and adds context loses its own context. The prompt that a `Stop` block queues is different: no `UserPromptSubmit` hook runs for it at all, as in Codex. A `UserPromptSubmit` guard cannot check it.

After 3 `Stop` continuations for one message, Xum logs that the cap was reached and ends the turn. For a turn that an ACP client or `xum run` owns, Xum runs the `Stop` hooks but never continues the turn. For a goal turn, Xum continues only while the goal can take another turn: not after it is paused or replaced, or after it reaches its turn, cost, or budget limit.

`hookSpecificOutput` must have `hookEventName` set to the event name. `Stop` has no `hookSpecificOutput`.

A rewrite in `updatedInput` must match the tool's input schema and pass the tool's own input parser (its refinements and defaults). If it does not, Xum denies the call. The tool runs with the parsed value. For `Bash`, set `updatedInput.command`. The call keeps its other Xum arguments.

The transcript keeps the model's original tool call. Xum adds a note to the tool result that names the fields the hook changed, for example `[plugin:guard] Rewrote this tool call's input (fields: script).` The note never contains the values, because a hook can put a secret into the input, and the model provider and the chat log see the note.

Xum honors an explicit deny or block when the rest of the output breaks a Codex rule, for example with an unknown field. It does not honor one when the output is malformed (a wrong type or a value outside an enum). See the list below. Xum does not support `permissionDecision: "ask"`, `updatedMCPToolOutput`, or `suppressOutput`.

### What fails open

In these cases, Xum logs a warning and continues without the hook's output:

* The hook times out, errors, or returns an MCP error.
* The server or tool is missing, disabled, or not running.
* The output is malformed: invalid JSON, a wrong type, or a value outside an enum. A deny or block in the same output is ignored too.
* The output has an unknown field. Xum then ignores the hook's other output, with two exceptions: on `PreToolUse`, `PostToolUse`, and `UserPromptSubmit` an explicit deny or block still applies, and a `PreToolUse` rewrite (`updatedInput`) makes Xum deny the call, because the hook allowed only its rewrite. On `Stop`, an unknown field voids the whole output, a block included. On every event, Xum still shows a `systemMessage` to you.
* The result text is over 1 MiB (1,048,576 characters).

One case fails closed. If the filled `input` of a `PreToolUse` hook is over 64 KiB (65,536 characters) as JSON, and the hook can run, Xum denies the call. A model can pad a command to get past a guard, so Xum does not let it through.

### Size limits

| Limit | Value |
| - | - |
| One text field of an output (`reason`, `permissionDecisionReason`, `stopReason`, `additionalContext`, `systemMessage`) | 16 KiB characters |
| Joined tool-event context or `PostToolUse` feedback of one plugin on one call | 32 KiB characters |
| All `UserPromptSubmit` context on one message, as rendered for the model | 64 KiB characters |
| One `hooks.json` file | 256 KiB |
| Hooks per plugin | 256 |

Xum cuts the joined tool-event text at 32 KiB and adds `[plugin hook context cut]`. On `UserPromptSubmit`, Xum drops the context entries after the 64 KiB cap.

### Known gaps

* `PostToolUse` sees the MCP result after Xum's transform, not the raw MCP response. Xum removes protocol `_meta`, cuts large text, and converts binary content to Xum's `{ type: "content", value: [...] }` shape.
* `UserPromptSubmit` has no `turn_id` and no `model`.
* Only `bash` and MCP tools have Codex names. Hooks match file edits, `task`, and other tools by their Xum names.

## Xum-native hooks

A plugin can ship `hooks.js` in its root, or at the path that `contributes.hooks` in `plugin.json` names. Xum loads `hooks.js` from global plugins, and from project plugins in a trusted project. A package with only `.codex-plugin/plugin.json` gets no `hooks.js`. Xum has no per-hook trust step for `hooks.js`. The install preview discloses it.

`hooks.js` is a script that evaluates to an object of hook functions. It runs in a QuickJS sandbox and cannot call tools. A function can be `async`. Each call has 5 seconds.

Tool hooks see only the tools that `plugin.json` lists in `extensions.mux.hooks.tools`. With no list, they see no tool. Tool hooks get Xum's tool names and arguments, for example `bash` and `args.script`.

```json plugin.json theme={null}
{
  "extensions": { "mux": { "hooks": { "tools": ["bash"] } } }
}
```

```js hooks.js theme={null}
({
  "tool.execute.before": (input) => {
    if (input.toolName === "bash" && input.args.script.includes("--force")) {
      return { deny: "Force flags are blocked." };
    }
  },
});
```

| Hook | Input | Output |
| - | - | - |
| `tool.execute.before` | `toolName`, `args`, `workspaceId`, `cwd`, `settings` | `{ deny: "reason" }` blocks the call. `{ args: {...} }` replaces the arguments. |
| `tool.execute.after` | `toolName`, `args`, `workspaceId`, `result` or `resultOmitted`, `settings` | `{ annotation: "text" }` adds the text to the result as `hook_output`. |
| `request.assemble` | `workspaceId`, `modelString`, `settings` | `{ context: "text" }` adds the text to the system prompt. |

`settings` holds the structured settings (`openai/settings`) of the plugin's own connected MCP servers, keyed by server name. These fields tell a hook that Xum left out data:

* `settingsOmitted: true`: the settings JSON is over 64 KiB (65,536 UTF-8 bytes). The hook gets `settings: {}`.
* `resultOmitted: true`: the tool result is over 256 KiB (262,144 characters) as JSON, or Xum cannot serialize it as JSON. `tool.execute.after` gets no `result`.

A hook that crashes, times out, or returns a malformed output never breaks the turn. Xum logs it and continues. Xum ignores an `args` rewrite that is not an object. Xum drops `request.assemble` context over 64 KiB (65,536 characters). Xum adds an annotation only to an object result.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.