Repository navigation
Plugin Events
English · 中文
Tracks v0.12.0 (
3a762b1). The ABI iswit/nanopi-extension.wit(world extension-events); the grant is validated insrc/agent/hook::parse_event_grants, the intersection is computed insrc/wasm/loader.rs(which is also where thetry_lock-and-drop delivery lives), and emission is driven fromsrc/subscriber.rs. Design notes and the full rationale:docs/v0.12-events.md. Re-verify with:cargo test --features wasm -- --test-threads=1
New in v0.12: a plugin can observe all eleven lifecycle events — the same events, with the same payloads, that a shell hook sees. See Hooks for what each event is and when it fires.
A plugin cannot block an event, cancel a turn, or rewrite a prompt. That is not a permission check that could be relaxed later; it is expressed in the types:
-
handle-event's return value is discarded. Return"{}". - The host-side trait method returns nothing at all, so there is no way for an implementor to hand back a veto or a transform even by accident.
Why. Veto and transform are the user's privilege, exercised through a config file they wrote. Handing that to a sandboxed binary someone else shipped inverts who is in control. If you need to stop something, write a hook.
Delivery requires the event to be in both:
- the plugin's own
list-eventsexport — what it asks for, and - the
eventslist in its[[extensions]]entry — what you grant.
[[extensions]]
path = "~/.nanopi/extensions/audit-watcher.wasm"
events = ["tool_execution_start", "session_start", "session_shutdown"]export list-events: func() -> string
export handle-event: func(event: string, payload-json: string) -> stringlist-events returns a JSON array of PI event names, called once at load and never again:
["tool_execution_start", "turn_start"]The intersection is computed once, at load, and never re-derived. That is what makes a plugin's list-events unable to self-expand its own grant. An event the plugin asked for and you did not grant is named at startup, so a subscription that is silently doing nothing tells you why:
requested event "input" but the config's `events` did not grant it — not delivered.
Absent or empty events grants nothing. A plugin exporting list-events receives no deliveries at all until its requests appear in your config too — the same deny-by-default shape as url_allowlist and allow_tools.
The eleven PI names, which are exactly the [[hooks.*]] config keys:
tool_execution_start tool_execution_end input
before_agent_start turn_start turn_end message_end
session_start session_shutdown session_before_compact session_compact
An unknown name in events is a warning at plugin load, not a config error, and the warning lists the valid names. One of the four retired Claude Code names gets the same rewrite the hook config gets:
unknown event "pre_tool_use" — did you mean "tool_execution_start"? (see docs/v0.12-events.md §2.1)
Two names are permanently undeliverable to plugins and say so rather than being silently dropped: message_update and tool_execution_update fire per streaming delta — hundreds of boundary crossings per turn — and no amount of grant will change that.
An unknown
eventsentry cannot be a hard config error, because[[extensions]]as a whole has to stay ignorable-with-a-warning in the stock non-wasmbinary. Contrast a misspelled grant key, which is a hard error — see Configuration.
payload-json is the same JSON a shell hook receives on stdin for the same event, built by the same function. A plugin and a hook see exactly the same thing — Hooks#wire-protocol is the reference for both.
event is PI's name for the event: tool_execution_start, not pre_tool_use.
One nuance worth knowing: on tool_execution_start, the payload delivered to plugins carries the pre-transform arguments — what the model asked for — even when one of your hooks rewrote them. And a plugin still receives the event when a hook blocked the call: the block is stashed and delivery runs first, on purpose. An observe-only audit plugin must not have a blind spot exactly where policy refused something; the refused calls are the ones it cares about most.
Delivery is dropped without waiting if that plugin is already inside another guest call — a tool execution, or another event delivery. The host uses try_lock, never a blocking lock:
nanopi: dropped event delivery, plugin busy [event=turn_start]
Why dropping is the right answer. Events sit on the turn's critical path. A blocking lock would mean a slow plugin adds its latency to every event, and one plugin stuck in a loop would stall the agent. Since handlers are observe-only, a dropped delivery loses information and nothing else — no decision was riding on it. That trade only works because the return value is discarded, which is the second reason observe-only is structural rather than a policy.
A poisoned lock is not treated as busy: it is recovered, because one panicking call must not stop delivery for the rest of the session.
Each handler runs on spawn_blocking and is awaited. A handler that panics is logged, and delivery continues to the next subscriber — one bad plugin does not stop delivery to the rest. An event with zero subscribers costs a hashmap lookup and nothing else: the payload closure is never even called, which is why the event → subscribers index is precomputed at load rather than derived per emit.
Guest code inside handle-event gets 2 seconds, deliberately smaller than the 30 s a tool call gets. An event handler runs with nothing on screen, on the turn's critical path, and there is no result anyone is waiting for.
The budget applies to guest code only. A handler that calls host-http-get is bounded by that function's own 10 s timeout on top — so roughly 12 s worst case.
Do not fetch from an event handler. If a handler needs to reach the network, have it record what it saw in its store and do the work somewhere a human is waiting.
/tools lists every plugin currently watching:
Watching events (1 plugins)
audit-watcher tool_execution_start, session_start, session_shutdown
That section lists the effective subscription — the intersection, not what the plugin asked for. If an event you granted is missing from the row, the plugin never requested it; if a row is missing entirely, the plugin subscribed to nothing.
/tools also shows a Plugin grants row per plugin, where the same subscription appears as an events(...) token alongside the other grants. See Plugin-Capabilities#what-tools-tells-you.
events is a bigger capability jump than allow_fs or allow_network. Before v0.12 a plugin saw only what the model chose to hand it as tool arguments. Now:
- a
tool_execution_startsubscriber sees every tool call's arguments — every command, every path, every file write - an
inputsubscriber sees every prompt you type, verbatim - a
tool_execution_endsubscriber sees every tool's output
Combined with allow_network = true on the same plugin, that is an exfiltration channel, and nanopi warns about the combination at startup:
has both `events` and `allow_network = true` — this plugin can observe lifecycle
events AND reach the network, and could exfiltrate event payloads. Grant both only
if you trust the plugin.
Both capabilities are individually opt-in, which is why the pairing gets a warning rather than a refusal. But grant events with the same care you would grant a log shipper.
--no-hooks turns plugin event delivery off too. The delivery sites sit behind the same hooks_active() check as the shell hooks, so the emergency switch covers every lifecycle observer, not just the ones in your config.
The worlds are a linear ladder, not a lattice:
extension ⊂ extension-commands ⊂ extension-events
WIT cannot express an optional export — wasm-tools component new fails if a declared export is missing — so a plugin targeting extension-events must export list-commands and execute-command as well, even if it registers no commands. Two-line stubs are the accepted cost. A lattice would be four worlds today and eight after the next feature.
Imports, unlike exports, are additive and need no new world: they all live on world extension and are inherited upward.
Declaring events via list-events and not exporting handle-event is a hard load error. Unlike commands, there is no "just don't dispatch" fallback that makes sense once the plugin has said "deliver me these".
A missing list-events export, on the other hand, is soft: the plugin loads and subscribes to nothing. That is what keeps extension- and extension-commands-targeted plugins working unchanged.
list-events returning malformed JSON, trapping, or returning more than 32 names are all hard errors for that plugin.
The shape that works: read the payload, update your store, return "{}". If you want to tell the user something, host-notify is ungated (and rate-limited to 10 lines per plugin per turn). If you want to act on what you saw, that is allow_tools or allow_send_message — both separate grants, both on Plugin-Capabilities.
message_end's payload carries turn_count and response_length — not the assistant's text. A plugin that wants to react to what the model actually said cannot get it from an event today. That remains an open design question, not a feature waiting on a flag.
- What each of the eleven events is, and when it fires → Hooks
- Granting
eventsinconfig.toml→ Configuration - The other seven imports and their gates → Plugin-Capabilities
- Building the guest side → Plugin-Development
English
- Home
- Features
- Configuration
- Hooks
- Plugin Development
- Example Plugins
- Plugin Capabilities
- Plugin Events
- Debugging Plugins
中文
v0.12.0 · source of truth:
wit/nanopi-extension.wit