Skip to main content
Plugin hooks are experimental. Expect breaking changes while we iterate.
An Agent Plugin can ship hooks in two formats: For hooks in your own repository, use tool hooks instead.

Codex-format hooks

Xum reads the Codex CLI hooks format, 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

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”.
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 .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: 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: 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

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. 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

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.
plugin.json
hooks.js
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.