Plugin hooks are experimental. Expect breaking changes while we iterate.
For hooks in your own repository, use tool hooks instead.
Codex-format hooks
Xum reads the Codex CLI hooks format, so a Codex plugin’shooks.json works without edits.
Where they come from
Xum readshooks/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
/sidechat.
Trust a hook
Installing a plugin trusts none of its hooks. The install preview lists them.- Open Settings → Plugins and select Hooks on the plugin. You can also run Review Plugin Hooks… from the command palette.
- Read each hook. If Xum cut a long
input, select Show full input first. Trust stays off until you do. - Select Trust. Select Turn off to stop a hook.
- 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.
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”.
${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 exampleBash|Edit) matches a whole name, not a substring. - Any other value is a regex. Codex uses the Rust
regexdialect. Xum compiles it as a JavaScript RegExp. - A leading flag group of
i,m, ands(for example(?i)or(?is)) becomes RegExp flags. - Xum translates
(?P<name>...)to a JavaScript named group, and the\Aand\zanchors 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)), thex,U,u, andRflags,\pL,\p{...},\x{...},\b{start},\<,\>, nested or POSIX classes ([[:alpha:]]), and&&,--, or~~in a class.
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 readsstructuredContent.
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, andUserPromptSubmitan explicit deny or block still applies, and aPreToolUserewrite (updatedInput) makes Xum deny the call, because the hook allowed only its rewrite. OnStop, an unknown field voids the whole output, a block included. On every event, Xum still shows asystemMessageto you. - The result text is over 1 MiB (1,048,576 characters).
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
PostToolUsesees 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.UserPromptSubmithas noturn_idand nomodel.- Only
bashand MCP tools have Codex names. Hooks match file edits,task, and other tools by their Xum names.
Xum-native hooks
A plugin can shiphooks.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 getssettings: {}.resultOmitted: true: the tool result is over 256 KiB (262,144 characters) as JSON, or Xum cannot serialize it as JSON.tool.execute.aftergets noresult.
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.