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:

WorldExportsUse for
content-plugincontent-hookson-page-load, on-render
filter-pluginfilterstemplate filters
shortcode-pluginshortcodesshortcodes
route-pluginroutescustom HTTP routes
media-pluginmedia-hooksmedia discover/process
model-pluginmodelsdynamic document models
content-source-plugincontent-sourceinjected content
diagram-plugindiagramdiagram 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):

CapabilityInterfaceWhat it provides
Configurationconfigget(key), get-all() over the merged plugin config
Logginglogginglog(level, message) to the host log
Environmentenvironmentget-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 in allowed_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’s host_services list (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:

  1. Copy the current wit/ files from the Accent repository over the copies in your plugin. world.wit is yours and stays as it is.
  2. Set api_version = "0.2.0" in plugin.toml. In a JavaScript plugin, change the import specifiers to accent:plugin/<interface>@0.2.0.
  3. Rebuild. The bindings regenerate from the new wit/, and the only code that changes is code that walked the arena, which now reads a serde_json::Value (Rust) or a parsed object (JavaScript). A content, route, shortcode, or diagram plugin has no such code.