Writing a cox plugin in Rust
A cox plugin is a WebAssembly module plus a plugin.toml manifest. cox loads it with extism, gives it only the capabilities the user approved, and calls the exports it finds. This guide covers the Rust side: the cox-plugin-sdk crate in plugins/sdk. The design, including why each rule exists, is docs/design/plugins.md (PL); the ABI is its §4.
This page is for writing a plugin. To install and use one that already ships in this repository, see its own guide, e.g. docs/plugins/cursor.md for the Cursor external agent.
The API has shared, terminal-only, and desktop-only parts. The field index lists every manifest and ABI field under its part.
Setup
The Rust toolchain and its wasm32-unknown-unknown target come from the repository's mise.toml. mise install adds the target if it is missing.
# Cargo.toml
[package]
name = "turn-counter"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Not on crates.io yet: a path inside this repository, or
# { git = "https://github.com/pyrlyn/cox" } from outside it.
cox-plugin-sdk = { path = "../../sdk" }
mise exec -- cargo build --release --target wasm32-unknown-unknown
# → target/wasm32-unknown-unknown/release/turn_counter.wasm
Copy the .wasm next to plugin.toml under the name its wasm key gives. The manifest's schema is docs/plugin.schema.json; PL§2 shows every key.
Exports
A plugin is a set of handler functions and one register! call. init is required and comes first. Every other export is optional and exists only if you list it, because cox probes exports by name and treats a missing one as "not provided".
use cox_plugin_sdk::*;
fn init(_: InitIn) -> Result<InitOut, SdkError> {
Ok(InitOut {
commands: vec![CommandDecl { name: "reset".into(), description: "Reset the counter".into() }],
subscribe: vec!["turn_started".into()],
..InitOut::default()
})
}
fn on_event(batch: EventBatch) -> Result<Effects, SdkError> {
let turns = kv_get("turns")?.and_then(|v| v.as_u64()).unwrap_or(0);
kv_put("turns", &Value::from(turns + batch.events.len() as u64))?;
Ok(Effects { redraw: true, ..Effects::default() })
}
fn command(_: CommandIn) -> Result<CommandOut, SdkError> {
kv_delete("turns")?;
Ok(CommandOut::Nothing)
}
cox_plugin_sdk::register!(init => init, on_event => on_event, command => command);
The complete version is plugins/examples/rust, the reference plugin PL§13 asks every language to implement: it adds a failure-counting hook and a status segment, and keeps its counters in memory as well as in kv, because cox_render may not call kv_get.
A handler returns Result<output, E> for any error type that implements Display. An Err fails that one call: cox shows the message as a warning and carries on (extensions fail open).
register! key | Export | Input → output | Called |
|---|---|---|---|
init | cox_init | InitIn → InitOut | once per session; InitIn.config is your [plugins.<id>] table from cox's config ({} when absent), which you validate yourself; contributions you were not granted are dropped with a notice |
on_event | cox_on_event | EventBatch → Effects | for the events in InitOut.subscribe (PL§5) |
hook | cox_hook | HookCall → HookOutcome as Value | for the hooks in capabilities.hooks (PL§6) |
decide | cox_decide | Question → Advice | at the decision points in capabilities.decide (PL§6b) |
tool_subject, tool_risk, tool_call | cox_tool_* | ToolCallIn → String, Risk / ToolOutput as Value | for the tools in InitOut.tools (PL§7) |
provider_stream | cox_provider_stream | ProviderCall → Vec<ProviderEvent> as Vec<Value> | for an api = "plugin" provider (PL§7a) |
command | cox_command | CommandIn → CommandOut | /<id>:<name> on every surface (PL§8) |
key | cox_key | CommandIn → CommandOut | terminal leader keys only (PL§15.3) |
render | cox_render | RenderIn → Widget | for a status segment, panel or overlay (PL§8) |
render_item | cox_render_item | RenderItemIn → Option<Widget> | for the targets in ui.render (PL§8) |
shutdown | cox_shutdown | () → () | at session end, best effort |
Payloads that carry a cox protocol type (Event, HookOutcome, ToolOutput, ProviderEvent, Request) are serde_json::Value, because the SDK does not depend on the host crates. Their JSON shape is in docs/protocol.jsonschema; everything else is in docs/plugin-abi.schema.json.
Host functions
Each wrapper returns SdkResult<T>. SdkError::Host(AbiError) means cox refused or failed the call: the capability is not granted, the call is not allowed from the current export, the permission engine or budget said no, a cap was hit, or the deadline passed. Refusals are values, not traps, so a plugin can degrade instead of failing its whole call.
| Function | Capability | Not allowed from | Returns |
|---|---|---|---|
log(level, text) | — | — | (); goes to cox's log, rate-limited |
notify(level, text) | — | render | (); a transcript notice, level at most Warn, sanitized |
kv_get(key), kv_put(key, value), kv_delete(key) | kv | render | Option<Value> / (); within the store quota (64 KiB per value, 1 MiB per plugin, keys up to 256 bytes) |
context() | context | — | the event-folded session snapshot: session id, cwd, tier and model, usage totals, the last 50 items with their text, the todo list, compactions; secret-shaped text redacted |
invoke_tool(name, input) | invoke lists name | everything but on_event, command, key, tool_call | a ToolOutput; the call passes hooks, the permission engine and the sandbox like a model's call |
model_call(&ModelCall) | model | render | Vec<ProviderEvent>; at or below the granted tier, budget-gated, recorded in the cost ledger |
http(&HttpReq) | net lists the host | render | HttpResp; a [[provider]] host only from provider_stream, with cox adding the auth header |
output(line), cancelled() | — | everything but tool_call | () / bool: tool progress and cancellation |
redraw() | any ui | — | (); marks your slots dirty |
invoke_tool is refused inside hook, decide and provider_stream because the agent loop is waiting on those calls, so a tool run from inside them would deadlock.
Engine features
cox runs every plugin on one engine configuration; a module that needs a WebAssembly proposal outside it fails to load with a parse error.
| Feature | State | Why |
|---|---|---|
| tail calls, function references, GC | on | extism 1.30.0 always enables them |
exception handling (try_table, throw, tags) | on | extism's wasmtime-exceptions feature, turned on for the workspace (plan.md A61): Kotlin/Wasm output uses it unconditionally and does not parse without it (research.md R§4.3.5 P42–P43) |
| WASI | off | wasmtime 43 has an unfixed WASI filesystem advisory (RUSTSEC-2026-0269); it stays off until the extism bump (A55, T33.43) |
The exception switch is workspace-wide: it is one flag on the engine, not a per-plugin capability, so any plugin may use exceptions. It adds guest-visible instructions only; the host still gives each plugin its own engine and store, the same memory cap and the same per-call deadline. RUSTSEC-2026-0222 names exception-related embedder APIs (ExnRefPre::new, Tag::new), but it needs the host to move objects between engines, which neither extism nor cox does, so enabling the proposal does not make it reachable from a guest (deny.toml). The exception-handling support in wasmtime 43 itself has not been reviewed beyond that advisory; revisit it with the T33.43 bump.
The wire, for other languages
The SDK is a thin layer over one rule, which a guest in any language can follow with its own extism PDK:
- An export reads its input as one JSON value (an empty input means
null) and sets one JSON value as its output. A failure sets the extism error text and returns 1. - A host function lives in the import module
cox:host/v1and has the signature(u64) -> u64: it takes the offset of an extism memory block holding one JSON value and returns the offset of a block it allocated. The argument is the function's only argument, an object keyed by its argument names when it has several ({"level": "warn", "text": "…"},{"key": "turns", "value": 3},{"name": "read", "input": {…}}), ornullwhen it has none. The reply is{"Ok": value}or{"Err": AbiError}.
Shared
The manifest's package identity, resource limits, providers, models, agents, MCP servers, and shared capabilities apply wherever the plugin loads. surfaces optionally narrows loading to terminal, desktop, headless, or acp; when absent, it permits all four. InitIn.surface tells the guest which one opened the session. cox:host/v1, the exports above except cox_key, commands, renderers, and the status.left, status.right, panel, and overlay slots use this part. Headless and ACP sessions have no UI, so they omit UI grants. Span.link accepts an HTTPS URL or workspace-relative path and opens only on a person's click; unsupported links remain text.
Terminal-only
[capabilities.terminal] keys = true grants leader-key bindings declared in InitOut.keys (KeyDecl) and handled by cox_key. The older capabilities.ui.keys spelling is an api = 1 alias. cox:tui/v1 is reserved and currently has no functions. A desktop session drops returned keys with one informational notice. cox --plain maps to the terminal surface but draws no slots.
Desktop-only
[capabilities.desktop] accepts inspector, toolbar, palette, and notify. InitOut.desktop.inspector declares one TabDecl; cox_render fills its desktop.inspector slot. toolbar and palette hold ActionDecl values whose command must name a declared command, run through cox_command. CommandOut.open_inspector selects the tab. cox:desktop/v1 exposes cox_desktop_notify with a DesktopNotice and at most two NoticeAction values. Widget.image names a package-relative PNG (at most 256 KiB); the terminal shows its alt text. A desktop-only host call on another surface returns AbiError.not_on_this_surface. Contributions returned on an unavailable surface are dropped with one informational notice, and a plugin excluded by surfaces is skipped with a notice. One digest grant covers every surface.
API field index
Generated from manifest and ABI schemas by scripts/plugin_api_reference.py. Each entry lists a type's fields and enum cases; referenced types have their own entries. Run python3 scripts/plugin_api_reference.py --write after an intentional schema change.
Shared
- manifest
PluginManifest:api,surfaces,id,version,name,description,wasm,wasi,limits,capabilities,provider,models,mcp,external_agents,cloud_agents,agents - manifest
Surface:terminal,desktop,headless,acp - manifest
Limits:memory_mib,call_ms - manifest
Capabilities:events,hooks,tools,invoke,context,kv,model,net,fs,decide,ui - manifest
ModelTier:cheap,code - manifest
FsCaps:read,write - manifest
UiCaps:status,panel,overlay,commands,render - manifest
ProviderDecl:name,api,base_url,api_key_env,auth - manifest
ProviderApi:chat,responses,plugin - manifest
ProviderAuth:bearer,x-api-key,none - manifest
ModelDecl:id,provider,context_window,max_output,price - manifest
PriceDecl:input,output,cache_read,cache_write - manifest
McpDecl:name,command,args,url - manifest
ExternalAgentDecl:name,command,args,mode,key_env - manifest
AgentMode:acp,stream-json - manifest
CloudAgentDecl:name,backend,key_env,model,description - manifest
CloudBackend:cursor - manifest
AgentDecl:name,file - ABI
InitIn:api,plugin_id,config,session,granted,surface - ABI
SessionInfo:id,cwd - ABI
Surface:terminal,desktop,headless,acp - ABI
InitOut:tools,commands,status,panels,renderers,subscribe - ABI
CommandDecl:name,description - ABI
Slot:status.left,status.right,panel,overlay - ABI
EventBatch:first_seq,dropped,events - ABI
Effects:redraw,notices - ABI
PluginNotice:level,text - ABI
NoticeLevel:info,warn - ABI
HookCall:event,payload - ABI
ToolCallIn:name,input - ABI
CommandIn:name,args - ABI
CommandOut:prompt,prompt.text,prompt.kind,compact,compact.focus,compact.kind,toggle_panel,toggle_panel.kind,open_overlay,open_overlay.kind,notice,notice.kind,nothing,nothing.kind - ABI
RenderIn:slot,width,height - ABI
RenderItemIn:target,call,result,width - ABI
ProviderCall:request,model - ABI
ModelCall:request,tier,own_provider - ABI
ModelTier:cheap,code - ABI
OwnProvider:name,model - ABI
HttpReq:method,url,headers,body - ABI
HttpResp:status,headers,body - ABI
Question:point,state,options,items - ABI
DecidePoint:route,risk,approve_hint,compact,rank,salience - ABI
QuestionItem:state,options - ABI
Advice:answer,confidence,note - ABI
Answer:choice,choice.order,choice.kind,score,score.value,score.kind,noul,noul.p_yes,noul.kind,scores,scores.values,scores.kind,items,items.answers,items.kind - ABI
DecideOut:call - ABI
DecideResume:question,events - ABI
AbiError:not_in_this_context,not_in_this_context.kind,not_on_this_surface,not_on_this_surface.surface,not_on_this_surface.kind,not_granted,not_granted.capability,not_granted.kind,denied,denied.reason,denied.kind,budget,budget.kind,too_large,too_large.limit,too_large.kind,timeout,timeout.kind,transient,transient.message,transient.kind,failed,failed.message,failed.kind - ABI
Widget:text,list,list.items,list.selected,table,table.header,table.rows,table.widths,key_value,gauge,gauge.ratio,gauge.label,stack,stack.vertical,stack.children,stack.sizes,block,block.title,block.child - ABI
Span:text,style,bold,italic,link - ABI
StyleToken:text,dim,accent,user,agent,tool,ok,warn,error,diff_add,diff_del,diff_hunk,border,selection
Terminal-only
- manifest
Capabilities:terminal - manifest
UiCaps:keys - manifest
TerminalCaps:keys - ABI
InitOut:keys - ABI
KeyDecl:key,name,description
Desktop-only
- manifest
Capabilities:desktop - manifest
DesktopCaps:inspector,toolbar,palette,notify - ABI
InitOut:desktop - ABI
Slot:desktop.inspector - ABI
DesktopOut:inspector,toolbar,palette - ABI
TabDecl:title,symbol - ABI
ActionDecl:command,title,symbol - ABI
CommandOut:open_inspector,open_inspector.kind - ABI
Widget:image,image.path,image.alt - ABI
DesktopNotice:level,title,body,actions - ABI
NoticeAction:label,command