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 – 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.