How to develop a plugin hooker
This guide explains how to create a plugin hooker without touching Rust code in the hooker crate.
0. The simplest way
Add your hooker under <your_xiaoO>/plugins/hookers. A subdirectory is recognized as a valid hooker only if it contains a plugin.json (refers to plugin.json-example). For file configuration details, see the sections below. If your hooker needs extra setup, add an install.sh in the hooker directory — it will be executed automatically. After adding a hooker, run <your_xiaoO>/plugins/hookers/install.sh manually and follow the prompts to configure.
1. What is a plugin hooker?
A plugin hooker is defined by JSON and executed by an external command.
That command can be:
- a Python script
- a shell script
- a compiled binary
- any other executable command available on the machine
2. How plugin hookers are loaded
Boot config uses HookerRegistryConfig.plugins.
This is a list of JSON file paths.
Each file:
- represents one plugin file source, often owned by one developer or one feature area
- must contain a JSON array
- each array item is one hooker
Example boot config shape:
[hooker]
default = "None"
plugins = [
"/absolute/path/dev-a-hookers.json",
"/absolute/path/dev-b-hookers.json"
]
enabled = []
disabled = []
policies = {}
Important:
- the example above shows the required JSON shape
- in real usage, you must replace
hook_pointwith a value that matches the actual runtime hook point in your app
3. Minimal JSON definition
Each plugin hooker item must contain three required fields:
{
"id": "plugin_read_file_pre_gate",
"hook_point": "*.Tool.builtin_read_file.pre",
"command": "python3 crates/hooker/tests/plugin/scripts/read_file_pre_gate.py"
}
Field meaning:
id: unique hooker id in the registryhook_point: where this hook should runcommand: shell command executed by the adaptor
You may add extra JSON fields. They are preserved in definition and passed to the plugin process.
4. How to choose the hook point
Current hook point format is:
agent.action.detail.stage
The action segment selects which hook family the entry belongs to. Today three families are supported:
Tool— wraps a tool invocation.stagemust bepre,post, orerror.Chat— wraps user input / system prompt assembly. Three sub-points exist (see sections 14 below):*.Chat.command.before,*.Chat.message.received,*.Chat.system.transform. These are matched as full hook points; the trailing segment is the hook name, not a free-formstage.Session— wraps session lifecycle events. Currently only*.Session.lifecycle.stateis emitted (see section 15).
Examples:
tool_cli.Tool.file_read.precli-agent.Tool.glob.post*.Tool.*.pre*.Chat.message.received*.Session.lifecycle.state
Wildcard support today:
- only full segment
* - allowed example:
*.Tool.*.pre - not allowed as wildcard:
tool_*
5. Important matching rule
Your plugin is not matched by id.
It is matched by hook_point.
That means the hook_point must agree with the real runtime values used by the caller.
For example, if the runtime generates:
tool_cli.Tool.file_read.pre
then these will match:
tool_cli.Tool.file_read.pre*.Tool.file_read.pre*.Tool.*.pre
but this will not match:
defaultagent.Tool.file_read.pre
6. Plugin process protocol
The adaptor runs your command with:
sh -c <command>
Then it:
- writes one JSON payload to stdin
- waits for the command to exit
- reads one JSON object from stdout
If the command exits non-zero, the hook is treated as failed.
7. Pre-hook protocol
Input payload
Typical pre-hook payload shape:
{
"stage": "pre",
"hooker": {
"id": "plugin_read_file_pre_gate",
"hook_point": "*.Tool.builtin_read_file.pre",
"command": "python3 script.py",
"agent_id": "tool_cli"
},
"call": {
"call_id": "tool-cli-call",
"tool_name": "file_read",
"input": {
"file_path": "/tmp/a.txt"
}
},
"policy": null,
"definition": {
"id": "plugin_read_file_pre_gate",
"hook_point": "*.Tool.builtin_read_file.pre",
"command": "python3 script.py"
}
}
Allowed output
Allow the call:
{ "result": "allow" }
Deny the call:
{ "result": "deny", "reason": "blocked by policy" }
Rewrite tool input:
{ "result": "transform", "modified_input": { "file_path": "/safe/path.txt" } }
8. Post-hook protocol
Input payload
The post-hook payload is like pre-hook, but also contains outcome.
Success example:
{
"stage": "post",
"outcome": {
"type": "success",
"output": "file content"
}
}
Error output example:
{
"stage": "post",
"outcome": {
"type": "error",
"message": "something went wrong"
}
}
Allowed output
Keep the original result:
{ "result": "accept" }
Rewrite successful output text:
{ "result": "transform", "modified_output": "new output" }
9. Error-hook protocol
Input payload
The error-hook payload is like pre-hook, but also contains error.
Example:
{
"stage": "error",
"error": {
"type": "execution_failed",
"message": "command failed"
}
}
Allowed output
Keep propagating the error:
{ "result": "propagate" }
Recover with replacement output:
{ "result": "recover", "output": "fallback text" }
10. Example script
This repository already has a small pre-hook example:
- definition file:
crates/hooker/tests/plugin/tool_pre_read_file_example.json - script file:
crates/hooker/tests/plugin/scripts/read_file_pre_gate.py
What it does:
- checks that
stage == "pre" - reads
call.input.file_path - denies the call if the path is
/etc/passwd - otherwise allows it
Treat this repository example as a protocol example first. If you copy it into a real app, make sure the hook_point matches that app's real runtime hook point.
11. Common mistakes
Mistake 1: JSON file is not an array
Wrong:
{ "id": "only_one" }
Right:
[
{ "id": "only_one", "hook_point": "*.Tool.*.pre", "command": "python3 script.py" }
]
Mistake 2: hook point does not match runtime reality
If runtime uses tool_cli.Tool.file_read.pre, then defaultagent.Tool.file_read.pre will never trigger.
Mistake 3: stage is unsupported
Use only:
preposterror
Mistake 4: stdout is not valid JSON
Printing logs to stdout will break the protocol.
Write only the result JSON to stdout.
If you need logs, write them to stderr.
Mistake 5: non-zero exit code
If the command exits with failure, the adaptor treats the hook as failed.
12. Practical advice
- start with a pre-hook because it is easiest to reason about
- use
*.Tool.*.preif you want broad coverage - keep plugin scripts small and deterministic
- print protocol JSON only to stdout
- keep extra metadata in the definition JSON if your script needs custom settings
13. Checklist before you say "my plugin does not work"
- is the
plugin_hookfeature enabled in the app crate? - is the plugin file path listed in
HookerRegistryConfig.plugins? - is the plugin file a JSON array?
- does each item have
id,hook_point, andcommand? - does your
hook_pointreally match the runtime hook point? - does your script exit with
0? - does your script write valid JSON to stdout?
- for chat/session hooks, does your
payload.stagematch the adaptor's stage string (command_before/chat_message/system_transform/session_state)? - for session state hooks, is your result exactly
{"result":"ack"}(or the aliasacknowledged)? Note that chat-hook result tags likeallow/acceptare not accepted here and will be treated as a failure.
14. Chat hook protocol
The three chat hooks (*.Chat.command.before, *.Chat.message.received, *.Chat.system.transform) are mutable hooks: a plugin may rewrite or deny the input flowing through the agent loop. They share the same subprocess protocol as Tool hooks (sh -c <command>, one JSON payload on stdin, one JSON object on stdout, non-zero exit = failure), but the payload shape and the set of legal result tags differ per hook.
session_id: All three chat hooks carry
payload.session_id. It is the same value in local and remote mode — the TUI sends its ownsession_idto the daemon viaRuntimeTurnRequest, and the daemon reuses it verbatim, so a plugin never needs to detect which mode it is running in. Just readpayload.session_id. (*.Chat.system.transformtypes it asOption<String>, so it may serialize asnull; guard withpayload.session_id || "(unknown)".) Toolprehooks and the session lifecycle state hook also carrysession_id; Toolpost/errorand all*.Llm.*hooks currently do not — see the table indocs/plugins.md"session_id 获取".
14.1 How chat hooks are dispatched
Inside run_agent_loop, on the append_user_message == true branch, xiaoo fires them in this fixed order:
*.Chat.command.before— only when the turn originated from a slash command (CommandContext.is_some()). ADenyshort-circuits the whole turn: xiaoo writes a refusal assistant message and returnsCompletewithout ever calling the model.*.Chat.message.received— fires for every user message before it is persisted, including queued follow-up turns drained bydrain_pending_user_messages.*.Chat.system.transform— fires insidebuild_messagesafter the prompt builder produces the orderedsystem: Vec<String>parts and before they are joined into the single system message. ATransformrewrites bothresult.system_partsandrequest.messages[0]so the LLM actually sees the new system text.
Ordering intent: command.before rewrites the command-layer body, which feeds message.received, which feeds system.transform. Each downstream stage sees the upstream mutation.
Hooks are discovered via runtime_view.hookers().list_for_hook_point, filtered by is_enabled, sorted by id (predictable order), and invoked sequentially. Each invocation is wrapped in a trace span (Hook kind). A single hooker failure (spawn failure, non-zero exit, invalid JSON, missing field, unsupported result tag) is logged to tracing::warn! and recorded in the error span, then the loop continues — only command.before's Deny actually short-circuits.
14.2 payload.stage values
payload.stage is a discriminator string hardcoded by each adaptor's build_*_payload. It is not the trailing segment of hook_point:
hook_point (in plugin.json) |
payload.stage |
|---|---|
*.Chat.command.before |
command_before |
*.Chat.message.received |
chat_message |
*.Chat.system.transform |
system_transform |
Scripts should branch on payload.stage rather than re-parsing hook_point.
14.3 *.Chat.command.before
Fires after a slash command template is expanded into body and before that body is submitted as a user turn.
Input payload shape:
{
"stage": "command_before",
"hooker": { "id": "...", "hook_point": "*.Chat.command.before", "command": "...", "agent_id": "..." },
"metadata": { ... },
"command": "review",
"session_id": "s1",
"arguments": "src/main.rs",
"body": "Review this carefully.\n\nsrc/main.rs",
"policy": null,
"definition": { ... }
}
Legal output:
{ "result": "allow" }
{ "result": "transform", "body": "rewritten body" }
{ "result": "deny", "reason": "blocked by policy" }
deny short-circuits the turn: xiaoo writes a refusal assistant message and returns Complete. reason is optional and defaults to "denied by plugin".
14.4 *.Chat.message.received
Fires when a user ChatMessage is constructed but before it is persisted to message history. Also fires for queued follow-up turns.
Input payload shape (the message field is a full ChatMessage object — role, blocks, timestamp_ms, etc.):
{
"stage": "chat_message",
"hooker": { ... },
"metadata": { ... },
"session_id": "s1",
"agent": "defaultagent",
"model": { "provider_id": "...", "model_id": "..." },
"message_id": null,
"message": {
"role": "user",
"blocks": [ { "type": "text", "text": "hello" } ],
"timestamp_ms": 0,
"message_id": null,
"api_usage_tokens": null,
"reasoning_content": null,
"estimated_tokens": null
},
"prior_message_count": 0,
"policy": null,
"definition": { ... }
}
prior_message_count is the number of messages already in the conversation history at the moment this hook fires. Because the hook fires before the current user message is persisted, this count reflects prior messages only — it does NOT include the message under inspection. Use it to detect the "first effective user input" of a session:
| Scenario | prior_message_count |
|---|---|
| Brand-new session, first user message | 0 |
| First turn done (user+assistant persisted), second user message | 2 |
| User persisted but assistant interrupted, new message arrives | 1 |
The idiomatic check is prior_message_count <= 1 (not === 0): the <= 1 buffer covers retry / interrupted-recovery where only a user message was persisted without an assistant reply, so the current input is still logically the session's first effective turn. This mirrors opencode's chat.message first-message detection, but computed in-process from the shared message store (no HTTP callback needed).
Legal output:
{ "result": "accept" }
{
"result": "transform",
"message": {
"role": "user",
"blocks": [ { "type": "text", "text": "redacted" } ],
"timestamp_ms": 0,
"message_id": null,
"api_usage_tokens": null,
"reasoning_content": null,
"estimated_tokens": null
}
}
A Transform replaces the entire message object. The returned message must be a valid ChatMessage (all fields present); otherwise the hooker is treated as failed and skipped.
14.5 *.Chat.system.transform
Fires inside build_messages after the prompt builder produces the ordered system: Vec<String> parts and before they are joined into the single system message.
Input payload shape:
{
"stage": "system_transform",
"hooker": { ... },
"metadata": { ... },
"session_id": "s1",
"model": { "provider_id": "...", "model_id": "..." },
"system": [ "base instruction", "second part" ],
"policy": null,
"definition": { ... }
}
Legal output:
{ "result": "allow" }
{ "result": "transform", "system": [ "new", "parts" ] }
Transform replaces the entire system array. The replacement is written into both result.system_parts and request.messages[0] (the system message text), so the LLM sees the new system prompt. Multiple hookers chained: each one receives the previous one's Transform output as input.
14.6 action: "ask_user" interaction
The three chat hooks support an interactive protocol. Instead of returning a result, a plugin may return:
{
"action": "ask_user",
"request": {
"kind": "confirm",
"prompt": "Allow rewriting the system prompt?"
},
"continuation": { "any": "value" }
}
request.kind selects the interaction widget:
confirm—{ "kind": "confirm", "prompt": "..." }text_input—{ "kind": "text_input", "prompt": "..." }(non-secret)choice—{ "kind": "choice", "prompt": "...", "options": ["a","b"], "allow_custom_input": true }
xiaoo presents the widget to the user. After the user answers, xiaoo calls the same plugin command again with the original payload augmented by an interaction field:
{
...original payload...,
"interaction": {
"request": { ...the InteractionRequest that was shown... },
"response": { ...the InteractionResponse the user gave... },
"continuation": { "any": "value" }
}
}
The plugin inspects interaction.response and either returns another action: "ask_user" (loop) or returns a result (final). When action is absent or "final", the result is treated as the hook's terminal output. This lets a plugin gate a Transform/Deny behind explicit user consent.
The session state hook (section 15) does not support ask_user — it is event-only.
15. Session lifecycle state hook protocol
*.Session.lifecycle.state is an event-style observer hook. It is dispatched by CoreBackedSessionService::run_turn in the gateway layer (not inside agent_loop) after a non-error root turn termination, when the session returns to the idle state.
15.1 Contract
- The only legal result is
{"result":"ack"}(the aliasacknowledgedis also accepted for ergonomics). Any other tag — includingtransformand the chat-hook tagsallow/accept— is rejected and the hooker is treated as failed, so a plugin that mistakenly reuses a chat-hook result tag gets a loud error rather than silent acceptance. - There is no
transform/denypath. The event carries no mutable output — plugins are observers. - The lifecycle state tag is carried in
payload.state, not in the hook point. Today only"idle"is emitted (after any non-error turn termination). TheStringtype is intentional so future call sites can emit"running"/"failed"/ ... without changing this contract or breaking existing plugins. - The turn's terminal kind is carried in
payload.outcome("complete"/"max_turns_reached"/"budget_exhausted"/"cancelled"). It is populated for every fired event so plugins can distinguish a normal completion from a soft termination while still seeing the samestate="idle". - Dispatch is fire-and-forget:
run_turnclonessession_id/sender_id/agent_id, callshandle.run_turn(...), and only if that returnsOkdoes ittokio::spawna background task that invokes all registered state hookers.run_turnreturns its originalturn_resultto the caller immediately — the spawn handle is not awaited. - Plugin errors (spawn failure, non-zero exit, invalid JSON, unsupported result) are logged via
tracing::warn!and the loop continues to the next hooker. They never affect the turn result or downstream flows. - The hook is not wrapped in a trace span (unlike chat hooks). If you need observability, write to your own log file from inside the script.
15.2 Input payload shape
{
"stage": "session_state",
"state": "idle",
"outcome": "complete",
"hooker": { "id": "...", "hook_point": "*.Session.lifecycle.state", "command": "...", "agent_id": "..." },
"metadata": { ... },
"session_id": "s1",
"sender_id": "u1",
"agent_id": "defaultagent",
"policy": null,
"definition": { ... }
}
The hook point sent to the plugin is constructed as <agent_id>.Session.lifecycle.state, so agent_id is also available inside hooker.agent_id and at top level.
session_id:
payload.session_idis the current session id. It is identical in local and remote mode (the TUI forwards itssession_idto the daemon, which reuses it), so a plugin does not need to detect the run mode to obtain the correct id — just readpayload.session_id. In remote mode the hooker subprocess is spawned by the daemon process; in local mode by the TUI process. Thecreate_session/switch_sessionactions in the response only take effect in remote (daemon) mode (see 16.4); in local mode the hooker still runs andpayload.session_idis still correct, but requested actions are dropped by the TUI.
15.3 Legal output
{ "result": "ack" }
That is the entire protocol. The adaptor maps it to SessionHookResult::Acknowledged and discards anything else.
15.4 When idle is (and is not) fired
- Fired: after
handle.run_turn(...)returnsOk— i.e. any non-error turn termination. This covers all fourAgentOutcomevariants:Complete,MaxTurnsReached,BudgetExhausted, andCancelled. All four leave the session back inidle(ready for the next turn), sostate="idle"is correct for each; the variant is distinguishable viapayload.outcome. - Not fired: if
run_turnreturns anErr. The failure path currently emits no state event. - Not fired: for non-root turns or any code path that does not go through
CoreBackedSessionService::run_turn.
If your plugin needs to react to failures, hook a different signal today; this contract may grow new state values (e.g. "failed") in the future, but only idle is currently emitted.
15.5 Minimal example
#!/usr/bin/env node
const fs = require("fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
if (payload.stage === "session_state" && payload.state === "idle") {
// payload.outcome is one of: complete / max_turns_reached / budget_exhausted / cancelled
fs.appendFileSync("/tmp/xiaoo-session.log",
`[${new Date().toISOString()}] idle: session=${payload.session_id} agent=${payload.agent_id} outcome=${payload.outcome}\n`);
}
process.stdout.write(JSON.stringify({ result: "ack" }));
The state branch is intentional — the same hooker script will keep working unchanged when future xiaoo versions emit running / failed / etc.; you simply add another case. Reading payload.outcome lets audit-style plugins tell a normal completion apart from a soft termination without switching on state.
16. Plugin-requested session actions
Alongside the existing result field, a plugin response may carry an actions array. Each entry requests a side-effect action that the host executes after applying the primary result. The flagship use case is a *.Session.lifecycle.state hooker that, after observing a turn termination, asks xiaoo to create or switch to a different session (e.g. spawn a debug session when a tool failed, or hand off to a planner session when budget is exhausted).
16.1 Which hook can return actions
Today only the session lifecycle state hook (*.Session.lifecycle.state, section 15) parses the actions field from the plugin's stdout JSON. The chat and tool adaptors do not parse it — appending actions to a chat/tool response is a no-op today. This restriction is intentional: it keeps the mutability surface small and matches the post-turn, observer-only nature of the session state hook (the action runs after the turn terminates, so it cannot interfere with the turn that produced it).
16.2 Response shape
A session state hooker returns the usual {"result":"ack"} plus an optional actions array:
{
"result": "ack",
"actions": [
{ "kind": "create_session", "session_id": "debug-1" },
{ "kind": "switch_session", "session_id": "debug-1" }
]
}
Field rules:
resultis parsed by the existing adaptor logic (unchanged, non-breaking). For the session state hook it must still be"ack"(or the alias"acknowledged").actionsis optional. When absent, not an array, or empty, no side effects are requested.- Each entry is a tagged-union object:
{ "kind": "<variant>", ...fields }. Thekinddiscriminator is matchedsnake_case-style (seeHookActionincrates/agent-types/src/hook/action.rs). - Invalid entries (unknown
kind, missing required fields, wrong types, or non-object entries) are silently skipped — a single malformed action does not poison the rest of the array.parse_actionscollects only the entries that deserialize cleanly.
16.3 Action kinds
kind |
Required fields | Daemon-side effect | TUI-side effect |
|---|---|---|---|
create_session |
session_id: String |
Calls open_session (idempotent resume — opens a new session or resumes an existing one with the same id). |
Switches focus to that session (transcript restored from daemon state). |
switch_session |
session_id: String |
Calls open_session (idempotent resume — ensures the target session exists before the TUI tries to switch to it). |
Switches focus to that session (transcript restored from daemon state); no-op if already focused. |
send_prompt |
session_id: String, text: String |
Calls open_session (idempotent resume — ensures the target session exists); stamps chain_depth = emitting_turn_depth + 1 (overwriting any plugin-supplied value) so the cross-turn depth cap can be enforced. |
Switches focus to that session (no-op if already focused), echoes text locally as a user message, and submits the turn via POST /api/v1/runtimes/input so the daemon runs the agent loop and the TUI streams the response. Remote-mode only — in local mode the action is dropped by the TUI. |
send_prompt also accepts a chain_depth field, but it is host-controlled: plugins must not set it. The daemon overwrites any plugin-supplied value with emitting_turn_depth + 1 before forwarding, so a plugin cannot forge a low depth to bypass the cross-turn cap (see 16.9). The TUI relays the stamped value back via RuntimeTurnRequest.chain_depth so the resulting turn's depth is tracked.
Both kinds collapse to the same daemon call today (open_session is idempotent), but the two variants exist so future xiaoo versions can distinguish "create a brand-new session and seed it" from "switch the TUI to an existing session" without breaking the JSON contract.
16.4 Execution flow
The dispatcher changed from fire-and-forget to awaited for the session state hook specifically so actions can be collected before the turn result is returned.
Mode-dependent contract:
create_session/switch_session/send_promptactions only take effect in remote (daemon) mode. In local (non-daemon) mode the hooker is still invoked and its actions are still collected (so the hooker can usesession_statefor logging/auditing), but the actions are dropped at the TUI'sexecute_hook_actionstep becauseremote_base_url()is None — no session is created, no focus switch happens, no turn is submitted. See step 5 below.
The full flow:
run_turnreturnsOk(turn_result). The session is back inidle.CoreBackedSessionService::fire_session_state_hook_and_collect_actionsinvokes every registered*.Session.lifecycle.statehooker sequentially (sorted by id), awaiting each one. Theactionsarrays returned by all hookers are concatenated, then eachsend_promptis stamped withchain_depth = emitting_turn_depth + 1(overwriting any plugin-supplied value) and dropped if that value reachesmax_prompt_chain_depth(next_depth >= max, an exclusive upper bound —Npermits N turns total in a chain; see 16.9). The surviving actions are concatenated intoAppTurnResult.hook_actions. The whole collection is bounded by an overall deadline of 30s (SESSION_STATE_HOOK_OVERALL_DEADLINE, equal to one hooker's per-subprocess cap): a single legitimately slow hooker is unaffected, but the sum across N hookers is capped at 30s instead of N × 30s. On timeout the spawned task is aborted (the in-flight subprocess is reaped viakill_on_drop) and no actions are returned — best-effort. This step is identical in local and remote mode.- Remote mode only: The HTTP router (
apps/serverside/src/httpserver/router.rs) callsDaemonHookActionSink::execute_on_daemon(hook_actions). For each action the daemon runs the daemon-side effect (currentlyopen_sessionfor all three kinds — idempotent resume, ensuring the target session exists). Forsend_prompt, thetextand daemon-stampedchain_depthride along on the forwarded action; the daemon does not run the turn itself here (the SSE stream for the new turn can only be obtained by the TUI POSTing/runtimes/input). Actions that fail daemon-side execution are filtered out and logged viatracing::warn!; they are not forwarded to the TUI. (In local mode this step does not exist — there is no daemon, so noopen_sessionruns.) - Remote mode only: The surviving actions are bundled into the SSE
Doneevent'sactionsfield and sent to the TUI. They are emitted before theDoneevent itself — the TUI's receive loop exits as soon as it seesDone(it setsstream_rx = None), so any update sent afterDonewould never be drained. (In local mode actions travel viaSessionTurnUpdate::HookActionson the in-process channel, not via SSE.) - The TUI buffers the actions in
pending_hook_actions, then the App event loop drains them viatake_pending_hook_actions()and dispatches each viaexecute_hook_action:create_session/switch_session: callsswitch_to_remote_session(session_id). (switch_sessionskips if already focused on the target, avoiding a redundant transcript reload + system message.)- Remote mode: switching focus re-calls
open_session(idempotent) to fetch the target session'sSessionRecordand reconstructs the local transcript fromloop_state.messagesso the user sees the prior turns instead of a blank transcript. - Local mode:
switch_to_remote_sessionchecksremote_base_url(), findsNone, logstracing::warn!("switch_to_remote_session called without remote backend; hook action dropped (local mode does not execute daemon-side actions)"), and returns without switching. The action is a no-op.
- Remote mode: switching focus re-calls
send_prompt: ifremote_base_url()is None (local mode), drops the action withtracing::warn!("send_prompt hook action dropped (local mode does not execute daemon-side actions)"). Otherwise: switches focus tosession_id(skipped if already focused), then — if a turn is already running (is_loading = true) — enqueues thetext(carryingchain_depth) intopending_turnsforstart_next_queued_turnto drain once idle; else callsstart_turn_for_hook_prompt(text, chain_depth), which echoestextas a user message (Message::user(prompt)) and POSTs/api/v1/runtimes/inputwithchain_depthset on the request. The daemon runs the agent loop; the TUI streams the SSE response. When that turn ends, step 2 repeats with the newemitting_turn_depth = chain_depth, re-enforcing the cap (16.9).
Trade-off vs the previous fire-and-forget design: the user sees the turn result after the session state hookers finish (or time out). This is acceptable because session lifecycle hooks only fire after turn termination anyway — there is no LLM streaming to interrupt — and most setups register zero or fast hookers. If your hooker script is slow (e.g. calls an external audit service), it will delay the
Doneevent proportionally; keepactions-emitting hookers fast.
16.5 Best-effort semantics
Actions are best-effort at every layer:
- Plugin layer:
parse_actionsskips invalid entries (see 16.2). - Daemon layer (remote mode only):
DaemonHookActionSink::execute_on_daemoncatches per-action failures (e.g.open_sessionreturnsErr) and filters them out — the failed action is logged viatracing::warn!withaction,session_id, anderrorfields, and is not forwarded to the TUI. - Collection layer:
fire_session_state_hook_and_collect_actionsenforces a 30s overall deadline (SESSION_STATE_HOOK_OVERALL_DEADLINE). If the sequential hooker chain does not finish in time, the task is aborted and all actions (including ones from hookers that already finished) are dropped —Doneis delivered with an emptyactionsarray. Keepactions-emitting hookers fast. - TUI layer:
switch_to_remote_sessionis best-effort. In remote mode, if the daemon-sideopen_sessionsomehow failed and the action was still forwarded (it shouldn't be, but defensive coding applies), the TUI logs atracing::warn!and continues without crashing. In local mode, the action is dropped at theremote_base_url() == Nonecheck (also viatracing::warn!) — this is the intended contract, not a failure.
No action failure is propagated back to the caller of the hook. A plugin that requests an unreachable session_id simply sees no session switch happen — the user is not shown an error.
16.6 Depth limiting
Two distinct depth limits apply to actions:
- Per-batch cap (
MAX_ACTION_DEPTH = 3): prevents a single hook response from chaining too many actions in one batch.DaemonHookActionSink::execute_on_daemontruncates any batch longer than the limit: only the firstMAX_ACTION_DEPTHentries are processed, the trailing actions are dropped and logged viatracing::warn!withrequestedandmaxfields. The canonicalcreate_session + switch_session + send_prompttriple exactly fills this budget; do not batch more than three actions in a single response. - Cross-turn cap (
max_prompt_chain_depth, default 128): prevents asend_prompt-triggered turn from firing anothersend_promptindefinitely across turns. See 16.9 for the round-trip and enforcement details.
Do not rely on chaining actions across multiple turns beyond the cross-turn cap, or batching more than three actions in a single hook response.
16.7 Minimal example
A *.Session.lifecycle.state hooker that opens a debug session whenever a turn ends with the budget_exhausted outcome, switches the user's focus to it, and sends a follow-up prompt so the agent continues investigating on the new session:
#!/usr/bin/env node
const fs = require("fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
let result = { result: "ack" };
if (payload.stage === "session_state" && payload.state === "idle") {
fs.appendFileSync("/tmp/xiaoo-actions.log",
`[${new Date().toISOString()}] idle: session=${payload.session_id} outcome=${payload.outcome}\n`);
if (payload.outcome === "budget_exhausted") {
// Spawn a debug session on the daemon, switch the TUI to it, and send
// a follow-up prompt. The daemon calls open_session (idempotent resume)
// for all three actions first; the TUI then switches focus and submits
// the prompt so the agent auto-continues on the new session.
//
// Order matters: send_prompt MUST come last (see 16.8) — a following
// switch_session would reset TUI state and interrupt the just-started
// turn's stream. The create+switch+send_prompt triple exactly fills
// the max action depth of 3.
const debugId = `debug-${payload.session_id}`;
result = {
result: "ack",
actions: [
{ kind: "create_session", session_id: debugId },
{ kind: "switch_session", session_id: debugId },
{ kind: "send_prompt", session_id: debugId, text: "Continue investigating the failure you were working on." }
]
};
}
}
process.stdout.write(JSON.stringify(result));
Notes on this example:
- The three actions are dispatched in order: the daemon opens
debug-<src>first (or no-ops if it already exists), then ensures the same id exists again (idempotent), then ensures it exists once more for thesend_promptand stamps itschain_depth. The TUI switches focus once (create), skips the redundant switch (already focused), then echoes the prompt and starts the turn. - If
open_sessionfails on the daemon (e.g. the daemon's session store is unhealthy), all three actions are filtered out and the TUI never sees them; the user stays on the original session. - Combining
actionswithask_useris not supported — the session state hook does not implement theask_userinteraction protocol (section 14.6). If you need user confirmation before opening a debug session, returnactionsunconditionally and let the user dismiss the new session manually, or move that logic into a*.Chat.command.beforehooker instead. - The
textofsend_promptwill pass through the daemon's*.Chat.message.receivedhook on the resulting turn (see 16.8 re-entrancy). If the same hooker handleschat_message, do not re-emitsend_promptfrom there.
16.8 Cross-turn re-entrancy and ordering constraints
send_prompt triggers a normal turn on the target session. That turn's lifecycle (*.Chat.message.received at start, *.Session.lifecycle.state at end) fires again, so plugin authors must be aware of two re-entrancy surfaces:
chat_messagesees the plugin's owntext: the daemon'sagent_loopfires*.Chat.message.receivedfor thesend_prompttext exactly as for a user-typed message. If the same plugin handles bothchat_messageandsession_state, itschat_messagebranch will observe thetextit itself emitted. Do not re-emitsend_prompt(or otherwise mutate) fromchat_messagebased on this self-generated input — it would form a second amplification path on top of thesession_statechain.prior_message_countsemantics: for a freshly created session, thesend_prompttext is the first message, sochat_messageseesprior_message_count = 0and any "first message" logic (context injection, memory load, etc.) fires for it. This may or may not be desired; design accordingly.- Echo vs transform: if a
chat_messagehooker transforms thesend_prompttext, the TUI echoes the original text locally while the daemon runs the turn with the transformed text. On the next transcript restore the TUI fetches the transformed version. This is inherited from remote +chat_messagetransform behavior, not new tosend_prompt.
Ordering within a single actions batch:
- The TUI processes actions sequentially in the order they appear.
send_promptmust be last and at most one per batch: aswitch_session(orcreate_session) after asend_promptwould reset TUI state (transcript reload,is_loadingcleared) and orphan the just-started turn's SSE stream. Themax action depth = 3plus the canonicalcreate + switch + send_prompttriple naturally enforces this. - If the TUI is already focused on the
send_prompt's target session, the switch step is skipped (no transcript reload, no "Switched to session" system message); the prompt is echoed and the turn submitted directly. - If a turn is already running when
send_promptis processed (is_loading = true, e.g. the previous turn's reveal buffer hasn't fully drained), the prompt is enqueued inpending_turnsand drained bystart_next_queued_turnonce idle — identical to a user typing while a turn runs.
16.9 Cross-turn send_prompt depth cap
A send_prompt-triggered turn ends by firing *.Session.lifecycle.state again, which may emit another send_prompt, forming a cross-turn chain. xiaoo does not impose semantic termination conditions on chains (that is the plugin's responsibility), but provides a host-side hard depth cap so a runaway plugin cannot loop forever:
- Config:
[hooker].max_prompt_chain_depth, default128. - Semantics:
Nis an exclusive upper bound onchain_depth. A chain may run N turns total — the user-initiated turn at depth0plusN - 1send_prompt-triggered turns at depths1..=N-1. Asend_promptthat would start the turn at depthNis dropped. - Enforcement point: the daemon's
fire_session_state_hook_and_collect_actionsstamps each collectedsend_promptwithchain_depth = emitting_turn_depth + 1(overwriting any plugin-supplied value). If the stamped value reachesmax_prompt_chain_depth(next_depth >= max_prompt_chain_depth), the action is dropped (not forwarded to the TUI) andtracing::warn!records it; the chain terminates there. - Round-trip: daemon stamps → TUI relays the stamped
chain_depthback viaRuntimeTurnRequest.chain_depth→ daemon records the new turn's depth → on that turn's end, stamps+1and re-checks. The cap is re-evaluated every turn, so there is no escape via the enqueue path or session switching. - Reset: a normal user-typed turn carries
chain_depth = 0, which immediately resets the chain. Human input always breaks an in-progress chain. - No forgery: plugins cannot set
chain_depthto bypass the cap — the daemon overwrites it unconditionally. The TUI is host code (not a plugin), so trusting it to relay the value is within the threat model.
[hooker]
max_prompt_chain_depth = 128 # default; lower it for debugging
The cap is exclusive on chain_depth: the daemon allows next = N + 1 while next < max, and drops at next >= max. So with the default of 128, a chain may run 128 turns total (1 user-initiated + 127 send_prompt-triggered) before being cut off. Setting N = 3 yields exactly 3 sessions: the user turn (depth 0) plus 2 send_prompt-triggered turns (depths 1 and 2); the send_prompt that would have started depth 3 is dropped.
16.10 Checklist before you say "my actions don't fire"
- is your hooker registered under
*.Session.lifecycle.state(the only hook point whose adaptor parsesactionstoday)? - does your response JSON include both
"result": "ack"and a top-level"actions"array? (actionsnested insideresultis silently ignored.) - is every entry an object with a
kindfield equal tocreate_session,switch_session, orsend_prompt(snake_case)? Unknown kinds are skipped. - does every entry carry its required
session_idfield as a string?send_promptadditionally requirestext. - did you not set
chain_depthonsend_prompt? It is host-controlled; the daemon overwrites it anyway. - is
send_promptthe last entry in youractionsarray, and at most one per batch? A followingswitch_session/create_sessionwould interrupt the just-started turn. - in remote/daemon mode, are you checking the daemon logs for
daemon-side create_session action failed; not forwarding to TUIwarnings? A failingopen_sessionfilters the action out before it reaches the TUI. - if your
send_promptchain seems to stop early, check the daemon logs forsend_prompt hook action dropped: chain depth reaches cap— you may have hitmax_prompt_chain_depth(default 128, exclusive upper bound onchain_depth;Npermits N turns total in a chain). Raise the cap or fix the plugin's termination logic. - in local (non-daemon) mode, are you aware that
create_session/switch_session/send_promptare no-ops? The hooker still runs (so your logging side-effects still happen), but the actions are dropped at the TUI'sswitch_to_remote_sessionbecauseremote_base_url()is None — you will see atracing::warn!readingswitch_to_remote_session called without remote backend; hook action dropped (local mode does not execute daemon-side actions)(for create/switch) orsend_prompt hook action dropped (local mode does not execute daemon-side actions)(for send_prompt). To exercise these actions, run the daemon and connect the TUI in remote mode (seedocs/remote_tui.md).