The Plugin Contract
The contract between Accent CMS and a plugin is a set of typed
WIT worlds. WIT
gives the boundary typed records, result<T, E> error handling, and structural
capabilities – there is no hand-serialised JSON string crossing the boundary.
The contract is the source of truth; both sides generate bindings from it.
Worlds: one per surface
A plugin implements exactly one world. The world names the host capabilities the plugin imports and the surface it exports. There is a focused world for each kind of plugin:
| World | Exports | Use for |
|---|---|---|
content-plugin | content-hooks | on-page-load, on-render |
filter-plugin | filters | template filters |
shortcode-plugin | shortcodes | shortcodes |
route-plugin | routes | custom HTTP routes |
media-plugin | media-hooks | media discover/process |
model-plugin | models | dynamic document models |
content-source-plugin | content-source | injected content |
diagram-plugin | diagram | diagram renderers (Standard) |
A focused world imports only the three always-granted host capabilities:
world content-plugin {
import config;
import logging;
import environment;
export content-hooks;
}
Structural, deny-by-default capabilities
A world’s imports ARE its capability grant. A plugin can reach only what its world imports, so a route plugin that never imports outbound HTTP simply cannot make a network call – the capability is absent from its instance, not merely disabled by policy.
Three capabilities are always granted (they have no WASI equivalent and carry no ambient authority):
| Capability | Interface | What it provides |
|---|---|---|
| Configuration | config | get(key), get-all() over the merged plugin config |
| Logging | logging | log(level, message) to the host log |
| Environment | environment | get-context(): production/debug flags, site name, URL, language |
Three capabilities are deny-by-default and appear only when a world opts in:
- Outbound HTTP (
outbound-http) – restricted to the hosts the manifest declares inallowed_hosts. A request to an undeclared host fails. - Host services (
host-services) – SMTP send and the plugin’s own data-store namespace, scoped by the manifest’shost_serviceslist (see Security). - Filesystem (
wasi:filesystem) – the standard WASI filesystem, confined to the host-mapped preopens (see Security).
A world that needs them imports them explicitly:
// A route + content plugin that also needs outbound HTTP. The network
// capability is present ONLY because this world imports outbound-http.
world networked-route-plugin {
import config;
import logging;
import environment;
import outbound-http;
export content-hooks;
export routes;
}
Surface signatures
Each exported interface is a small set of typed functions. The most common:
// Content hooks: transform markdown before render, or HTML after.
interface content-hooks {
record content-input { content: string, page-path: string }
on-page-load: func(input: content-input) -> result<string, string>;
on-render: func(input: content-input) -> result<string, string>;
}
// Template filters: the host dispatches by filter-name.
interface filters {
record filter-input { filter-name: string, value: json, args: list<json> }
apply: func(input: filter-input) -> result<json, string>;
}
// Custom HTTP routes.
interface routes {
record request { method: string, path: string, query: string,
headers: list<tuple<string, string>>, body: string }
record response { status: u16, headers: list<tuple<string, string>>, body: string }
handle: func(req: request) -> response;
}
An err from a content hook or filter is logged and the previous value in the
chain is preserved – the same graceful skip-on-error behaviour as before, now
expressed in the type system.
Dynamic data: the json type
Where the data shape is inherently open – filter values, model defaults, media
metadata, frontmatter, data-store values – the contract uses a json type: the
bytes of one UTF-8 JSON document.
/// One UTF-8 JSON document. An empty list denotes the absence of a value.
type json = list<u8>;
Typed records stay typed; only this one open value crosses the boundary as bytes, because WIT forbids the recursive variant a JSON value would otherwise need. Reading and writing it is one call on either side:
- Rust:
serde_json::from_slice(&input.value)to read,serde_json::to_vec(&value)to write. - JavaScript:
JSON.parse(new TextDecoder().decode(input.value))to read,new TextEncoder().encode(JSON.stringify(value))to write.
An empty payload means “no value” and reads as null. The host refuses a
non-empty payload that is not one valid JSON document: a filter that returns
one fails the template with a message naming the plugin and the filter, a
malformed model default degrades to null with a warning, a malformed media
metadata entry is dropped with a warning, and a malformed data-store write is
refused as an invalid request. Decoding stops at 128 levels of nesting.
A content hook or route, which only moves strings, never sees the type at all
– which is why the scaffold starts you on a
content hook.
Upgrading to plugin API 0.2.0
Plugin API 0.2.0 changed one thing in the contract: in 0.1.0, json was a flat
arena of typed nodes (record json { nodes: list<json-node> }) that every
plugin had to walk; it is now the bytes above. The package is
accent:plugin@0.2.0, and the host no longer loads a 0.1.0 component. An
installed one is refused at startup with the remedy
run accent plugin update <name>, and the hub serves 0.2.0 builds of the
first-party plugins, so an update is the whole fix for a plugin you installed
from the hub.
To move a plugin you maintain:
- Copy the current
wit/files from the Accent repository over the copies in your plugin.world.witis yours and stays as it is. - Set
api_version = "0.2.0"inplugin.toml. In a JavaScript plugin, change the import specifiers toaccent:plugin/<interface>@0.2.0. - Rebuild. The bindings regenerate from the new
wit/, and the only code that changes is code that walked the arena, which now reads aserde_json::Value(Rust) or a parsed object (JavaScript). A content, route, shortcode, or diagram plugin has no such code.