Writing a cox plugin in Rust

From docs/plugins.md at 8dbc1da View on GitHub

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! keyExportInput → outputCalled
initcox_initInitIn → InitOutonce 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_eventcox_on_eventEventBatch → Effectsfor the events in InitOut.subscribe (PL§5)
hookcox_hookHookCall → HookOutcome as Valuefor the hooks in capabilities.hooks (PL§6)
decidecox_decideQuestion → Adviceat the decision points in capabilities.decide (PL§6b)
tool_subject, tool_risk, tool_callcox_tool_*ToolCallIn → String, Risk / ToolOutput as Valuefor the tools in InitOut.tools (PL§7)
provider_streamcox_provider_streamProviderCall → Vec<ProviderEvent> as Vec<Value>for an api = "plugin" provider (PL§7a)
commandcox_commandCommandIn → CommandOut/<id>:<name> on every surface (PL§8)
keycox_keyCommandIn → CommandOutterminal leader keys only (PL§15.3)
rendercox_renderRenderIn → Widgetfor a status segment, panel or overlay (PL§8)
render_itemcox_render_itemRenderItemIn → Option<Widget>for the targets in ui.render (PL§8)
shutdowncox_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.

FunctionCapabilityNot allowed fromReturns
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)kvrenderOption<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 nameeverything but on_event, command, key, tool_calla ToolOutput; the call passes hooks, the permission engine and the sandbox like a model's call
model_call(&ModelCall)modelrenderVec<ProviderEvent>; at or below the granted tier, budget-gated, recorded in the cost ledger
http(&HttpReq)net lists the hostrenderHttpResp; 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.

FeatureStateWhy
tail calls, function references, GConextism 1.30.0 always enables them
exception handling (try_table, throw, tags)onextism'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)
WASIoffwasmtime 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/v1 and 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": {…}}), or null when 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