Plugin Development
This guide covers the plugin surface that Carapace currently ships and loads at runtime.
The workflow documented here is:
- choose a plugin shape and WIT world
- build a WASM component against
wit/plugin.wit - load it locally through
plugins.load.paths - restart Carapace
- verify activation with
cara plugins statusandcara logs - use
cara plugins install/cara plugins updateonly when you want the managed distribution path
This guide is intentionally written around the public surfaces Carapace exposes:
- the WIT contract in
wit/plugin.wit - the runtime loader behavior in
src/plugins/* - the operator CLI in
cara plugins ...
What you can build
This guide covers these public plugin targets:
| Plugin shape | WIT world | Notes |
|---|---|---|
| Tool | tool-plugin |
Agent-callable tools |
| Webhook | webhook-plugin |
HTTP handlers under /plugins/<plugin-id>/... |
| Service | service-plugin |
Background lifecycle services |
| Channel | channel-plugin |
Channel metadata + adapter exports, plus hook exports for channel lifecycle integration |
Not covered here:
- Provider plugins:
provider-pluginexists in the WIT file, but the public manifest/runtime contract does not expose it as a supported plugin kind. - Hook-only or
full-plugincompositions: the runtime has hook support, but this guide stays on the direct public worlds above.
Two plugin workflows
Carapace has two distinct plugin workflows:
- Local development
- Use
plugins.load.paths - Fastest edit/build/restart loop
- Best default for authoring a plugin
- Use
- Managed plugins
- Use
cara plugins install/cara plugins update - Artifacts live under
state_dir/plugins - Artifact metadata lives in
plugins-manifest.json - Install lifecycle metadata lives in
plugins.entries - Intended for managed distribution, not your normal inner loop
- Use
If you are writing a new plugin, start with
plugins.load.paths.
Plugin identity, names, and manifest metadata
There are two different names you will see in the plugin tooling:
pluginId- comes from the
.wasmfilename stem at load time - identifies the runtime plugin instance
- appears in
cara plugins status - must be lowercase alphanumeric plus hyphens
- maximum length:
32 - should match the manifest
idif you embed explicit manifest metadata
- comes from the
- managed plugin
name- the name you pass to
cara plugins install <name>orcara plugins update <name> - identifies the managed artifact/install entry under
plugins.entries - may contain ASCII alphanumeric characters, hyphens, and underscores
- maximum length:
128
- the name you pass to
For simplest operations, keep the managed plugin name and the runtime
pluginId the same.
Reserved managed plugin names:
enabledentriesloadsandboxsignature
Core manifest fields:
idnamedescriptionversionkind
Optional manifest fields:
permissions
Carapace can load a plugin even if you do not embed explicit manifest metadata. The loader derives metadata in this order:
plugin-manifestcustom section, if present- component export inspection for the plugin kind
- file name / file metadata fallbacks
Inference details:
id: file stemname: component/module name if available, otherwise a display name derived from the file stemversion: file modification time, formatted as0.0.YYYYMMDDHHMMSSkind: inferred from exported interfaces, defaulting totoolif the component exports are otherwise unrecognizable
For reproducible managed distribution, prefer explicit manifest metadata rather than relying on inference.
Build target
Carapace plugins are WebAssembly Component Model components. Target
the package namespace declared in wit/plugin.wit:
package carapace:plugin@1.0.0;
Practical Rust setup with cargo-component:
cargo install cargo-component
cargo component new --lib my-pluginPoint your component target at Carapace's WIT file and choose the world that matches your plugin shape:
[package.metadata.component]
target = { path = "/absolute/path/to/carapace/wit/plugin.wit", world = "tool-plugin" }The absolute path above is just an example. A path relative to your
component crate or a package/dependency reference is also fine as long
as it resolves to the same wit/plugin.wit contract.
Build:
cargo component build --releaseUse the generated .wasm artifact from your component
build output directory. The exact target/.../release/ path
can vary by toolchain version; the thing that matters is the built
component file.
Any toolchain that produces a valid WASM component for the same WIT
contract is fine. Rust plus cargo-component is just the
most direct path.
Fastest path: first tool plugin
If you are building your first plugin, start with a tool plugin. It has the smallest surface and the fastest edit/build/restart loop.
Recommended sequence:
create a new component crate:
cargo install cargo-component cargo component new --lib my-toolpoint it at Carapace's WIT and select the
tool-pluginworld:[package.metadata.component] target = { path = "/absolute/path/to/carapace/wit/plugin.wit", world = "tool-plugin" }implement the required exports for that world:
manifest.get-manifest()tool.get-definitions()tool.invoke(...)
build the component:
cargo component build --releasecopy the generated
.wasminto a directory listed inplugins.load.pathsrestart Carapace and verify:
cara plugins status --port 18789 --name my-tool cara logs -n 200 --port 18789
If that path works, then move on to webhook, service, or channel plugins. The local development loop stays the same; only the WIT world and required exports change.
Shape-specific contracts
Tool plugins
A tool plugin built against tool-plugin exports:
manifest.get-manifest()tool.get-definitions()tool.invoke(...)
Its manifest kind should be tool.
Tool definition name rules:
- lowercase alphanumeric plus underscores
- maximum length:
64
Config and credential lookups are exact:
config-get("apiKey")readsplugins.<plugin-id>.apiKeycredential-get("token")reads<plugin-id>:tokencredential-set("token", value)stores<plugin-id>:token
Carapace does not translate api_key to
apiKey for you.
Webhook plugins
A webhook plugin built against webhook-plugin
exports:
manifest.get-manifest()webhook.get-paths()webhook.handle(...)
Webhook-specific behavior:
- webhook paths are mounted under
/plugins/<plugin-id>/... get-paths()returns paths inside that namespace, without the/plugins/<plugin-id>/prefix- request bodies are capped by
gateway.hooks.maxBodyBytesin the server config (default:256 KiB) - Carapace currently forwards request headers through to the plugin as-is
Service plugins
A service plugin built against service-plugin
exports:
manifest.get-manifest()service.start()service.stop()service.health()
Service-specific behavior:
start()runs when the plugin is activatedstop()runs during shutdown or unloadstop()should finish promptly so shutdown or unload is not blockedhealth()is part of the service ABI, but the current runtime does not poll it on a fixed interval
Channel plugins
A channel plugin built against channel-plugin
exports:
manifest.get-manifest()channel-meta.get-info()channel-meta.get-capabilities()- channel adapter methods such as:
send-text()send-media()send-poll()edit-message()delete-message()react()
hooks.get-hooks()hooks.handle(...)
Channel-specific behavior:
channel-info.iduses the same lowercase alphanumeric plus hyphen rule as plugin IDs- capabilities declare what the channel supports: polls, reactions, media, threads, group management, and so on
- the channel world includes hooks because channel plugins often need lifecycle integration in addition to outbound delivery methods
Advanced manifest path
If your build pipeline can embed a plugin-manifest
custom section, Carapace will use it directly instead of inferring
metadata from the file and component exports.
The JSON structure below shows the core PluginManifest
fields:
{
"id": "my-tool",
"name": "My Tool",
"description": "Example plugin",
"version": "1.0.0",
"kind": "tool"
}This is the most predictable path for managed distribution because it avoids:
- filename-stem/runtime-ID mismatches
- file-mtime-derived versions
- kind inference from exports
Embedding that custom section is toolchain-specific, so this guide
keeps the main workflow on the plain cargo-component path
and treats the custom-section manifest as an advanced option.
Local development walkthrough
Use plugins.load.paths for day-to-day development. Do
not use state_dir/plugins for your normal edit/build
loop.
Recommended config shape:
{
plugins: {
enabled: true,
load: {
paths: [
"/absolute/path/to/dev-plugins",
],
},
"my-tool": {
apiKey: "dev-key-here",
},
},
}
Concrete inner loop:
mkdir -p /absolute/path/to/dev-plugins
cargo component build --release
cp /path/to/generated/my_plugin.wasm /absolute/path/to/dev-plugins/my-tool.wasm
cara start --port 18789
cara plugins status --port 18789 --name my-tool
cara logs -n 200 --port 18789What success looks like in cara plugins status:
name: your configured plugin namepluginId: the loaded plugin filename stem / runtime plugin IDstate:activereason:null
Useful status fields to watch:
sourceconfigforplugins.load.pathsmanagedfor managed installs
enabledrequestedAtrestartRequiredForChangesactivationErrorCount
On each edit cycle:
- rebuild the component
- copy the new
.wasminto your dev plugin directory - restart Carapace
- rerun:
cara plugins status --port 18789 --name my-toolcara logs -n 200 --port 18789
Important behavior:
plugins.enabled = falsedisables both managed plugins andplugins.load.pathsplugins.load.pathsis trusted local input- never place untrusted
.wasmfiles in aplugins.load.pathsdirectory; plugins loaded from those paths can always read their plugin-scoped config, while access to credentials and outbound HTTP or media requests depends onplugins.sandbox.*and is denied by default unless you enable it - there is no hot reload
- plugin activation changes require restart
cara plugins status --jsonis the easiest way to inspect the full structured runtime state if the default table output is not enough
Managed plugins and distribution
Use managed plugins when you want the managed distribution path, not when you just want the fastest local development loop.
Managed plugin commands:
cara plugins install demo-plugin --file ./path/to/demo_plugin.wasm --port 18789
cara plugins update demo-plugin --file ./path/to/demo_plugin.wasm --port 18789
cara plugins bins --port 18789
cara plugins status --port 18789 --name demo-pluginImportant managed-plugin behavior:
- artifacts live under
state_dir/plugins - metadata lives in
plugins-manifest.json - install metadata lives under
plugins.entries.<name> - operational runtime config still lives under
plugins.<plugin-id>.* - install/update changes still require restart before activation
--fileis local-only; use it for loopback targets, not remote serverscara plugins binslists the managed binary filenames currently present on disk
plugins-manifest.json entries carry the managed artifact
metadata Carapace uses at load time, including:
sha256- optional
version - optional
publisher_key - optional
signature - optional
url - optional
path(absolute or relative tostate_dir/plugins); when omitted, Carapace defaults to<name>.wasm
plugins.entries.<name> carries the managed install
metadata that shows up in cara plugins status,
including:
enabledinstallIdrequestedAt
Do not put runtime configuration like apiKey, webhook
settings, or service options under
plugins.entries.<name>. Those still belong under
plugins.<plugin-id>.*.
Optional publisher metadata:
--publisher-key--signature
Those values are recorded at install/update time and enforced later
at plugin load time according to plugins.signature
policy.
Relevant config keys:
plugins.signature.enabledplugins.signature.requireSignatureplugins.signature.trustedPublishersplugins.sandbox.enabledplugins.sandbox.defaults.allowHttpplugins.sandbox.defaults.allowCredentialsplugins.sandbox.defaults.allowMedia
Host capabilities and sandbox boundaries
Every plugin imports the host interface from wit/plugin.wit.
Common host functions:
| Host function | Purpose |
|---|---|
log-debug/info/warn/error |
Structured plugin logs |
config-get(key) |
Read plugins.<plugin-id>.* config |
credential-get/set |
Plugin-scoped secret storage |
http-fetch(request) |
HTTP client with SSRF protection |
media-fetch(url, max-bytes, timeout-ms) |
Media fetch with SSRF protection |
Runtime limits worth designing for:
- memory:
64 MBper plugin instance - execution timeout:
30sper function call - HTTP limit:
100/minper plugin - log limit:
1000/minper plugin - HTTP body size:
10 MBmax - webhook paths live under
/plugins/<plugin-id>/...
Other behavioral rules worth knowing:
- config reads are always scoped to
plugins.<plugin-id>.* - credential reads/writes are always scoped to
<plugin-id>:... - outbound HTTP and media fetches go through SSRF protections
- plugin networking only supports
https
The WIT file is the authoritative ABI and capability reference.
Troubleshooting
- Plugin did not load:
- confirm
plugins.enabledis notfalse - confirm the
.wasmfile is inside a directory listed underplugins.load.paths - confirm you built a WASM component, not a core module
- restart Carapace and check both:
cara plugins status --port 18789cara logs -n 200 --port 18789
- confirm
config-get(...)returnedNone:- check the exact key under
plugins.<plugin-id>.* - use the same key name inside the plugin
- check the exact key under
- Managed install did not activate:
- restart Carapace
- run:
cara plugins status --port 18789 --name <name>cara plugins bins --port 18789
- check
plugins-manifest.jsoncompleteness andplugins.signaturepolicy
cara plugins statusshows a differentpluginIdthan you expected:- check whether your plugin is using inferred metadata from the file stem
- prefer explicit manifest metadata for managed distribution
cara plugins installorupdatesucceeded but the plugin is still not active:- managed install/update only stages the artifact and metadata
- activation still happens on restart