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 – the contract uses a json type. Because WIT forbids
recursive types, it is encoded as a flat arena of nodes rather than a recursive
variant: node 0 is the root, and array/object nodes reference their children
by index.
record json { nodes: list<json-node> }
variant json-node {
null-value, boolean(bool), integer(s64), float(f64), text(string),
array(list<u32>), object(list<tuple<string, u32>>),
}
Guest toolchains wrap this with ergonomic builders and readers, so you rarely
touch the arena directly. A content hook or route, which only moves strings,
never sees it at all – which is why the scaffold
starts you on a content hook.