Skip to content

Plugins and Marketplace

Plugins extend Wayland Core with extra providers and tools. The plugin subcommand installs them from a registry, lists what you have, and removes them. Plugins resolve from a local registry by default, or from a remote GitHub source.

Terminal window
wayland-core plugin available

This reads the local default registry shipped with the binary. To point at your own directory of TOML manifests instead:

Terminal window
wayland-core plugin available --registry-dir ./my-registry

Names are kebab-case. The default source is local, which reads the embedded registry or the directory you pass:

Terminal window
wayland-core plugin install wayland-honcho

To install from a remote source, use a github://<org> source spec, which resolves against GitHub Releases on that org:

Terminal window
wayland-core plugin install wayland-honcho --source github://ferroxlabs

Plugins install under the platform data directory by default (for example ~/Library/Application Support/wayland-core/plugins on macOS). You can override the location with --install-root, though that is mainly for tests and sandboxes.

Terminal window
wayland-core plugin list # installed plugins
wayland-core plugin remove wayland-honcho

Remote artifacts are verified before they are trusted. Releases are signed, and the engine checks the signature against a pinned marketplace public key (ed25519) before swapping anything into place. The same signing discipline gates self-update.


Two plugin runtimes exist. They share the same plugin.toml manifest format and the same [permissions] block, but differ in how the plugin code runs and what isolation they provide.

SubprocessWASM
Code formatNative binary.wasm Component Model component
IsolationNone (inherits engine uid, env, and filesystem)wasmtime sandbox: memory cap, fuel budget, epoch timeout
Secrets reach codeFull env (after env_clear allowlist)Never. Existence-check only via secret-exists.
Network from plugin codeUnrestricted (plugin’s own syscalls)Host-proxied, allowlist-gated
Restart on crashYes: crash budget (3 strikes), backoff 100 ms / 500 ms / 2 sN/A. Each call is a fresh component instance.
Implementation cratewcore-plugin-subprocesswcore-plugin-wasm

Subprocess plugins are native binaries. The engine spawns the binary, talks to it over JSON-Lines on stdin and stdout, and routes tool calls back through the same transport.

The implementation lives in crates/wcore-plugin-subprocess/src/runner.rs.

Drop a plugin.toml alongside the binary:

plugin_api_version = "1.0"
[plugin]
name = "my-tool"
version = "0.1.0"
description = "A native plugin"
entry = "my-tool"
license = "MIT"
[permissions]
register_tools = true
tool_namespace = "MyTool"
[runtime]
kind = "subprocess"
[runtime.subprocess]
binary_path = "my-tool"
args = []

binary_path is resolved relative to the directory containing plugin.toml.

The host sends one JSON object per newline-terminated line on stdin; the plugin responds on stdout in the same framing. Each request carries a monotonic id; the response echoes the same id. The verb sequence is:

  1. {"id":1,"verb":"init"}: plugin replies with manifest_version and a capability list.
  2. {"id":2,"verb":"list_tools"}: plugin replies with a tools array of {name, description, input_schema} descriptors.
  3. {"id":3,"verb":"call_tool","name":"…","input":{…}}: plugin replies with {stdout, structured, is_error}.
  4. {"id":4,"verb":"shutdown"}: plugin replies with {"id":4,"kind":"ack"} and exits.

Lines from the plugin’s stdout are capped at 8 MiB. A line exceeding that cap without a terminating newline tears the transport down, triggering the crash budget.

spawn_binary calls Command::env_clear() before spawning. Only an explicit allowlist is forwarded: PATH, HOME, USER, LANG, TZ, locale vars (LC_ALL, LC_CTYPE, LC_MESSAGES, LC_MONETARY, LC_NUMERIC, LC_TIME), TMPDIR, and Windows essentials (SYSTEMROOT, WINDIR, COMSPEC, PATHEXT, USERPROFILE, APPDATA, LOCALAPPDATA, PROGRAMFILES, TEMP, TMP, and others). Variables such as OPENAI_API_KEY, ANTHROPIC_API_KEY, and WAYLAND_VAULT_PASSPHRASE are withheld.

Each SubprocessPluginRunner holds an Arc<AtomicU8> consecutive-failure counter. The following events count as one strike: broken pipe or EOF, RPC timeout (30 seconds), JSON parse error, subprocess-side protocol error, and unexpected non-zero exit. On a strike:

  • If crash_count < 3: the runner restarts the subprocess in place, with backoff of 100 ms (strike 1), 500 ms (strike 2). It replays the init and list-tools handshake, then retries the failed call once on the fresh instance.
  • If crash_count >= 3: the plugin is auto-disabled for the lifetime of this runner. Further calls return an error immediately.

A successful call resets the counter (consecutive-only semantics).


WASM plugins are WebAssembly Component Model components. The engine runs them inside a wasmtime sandbox with hard limits on memory, CPU (fuel), and wall-clock time. Each tool call instantiates a fresh component with no shared mutable state between calls.

The implementation lives in crates/wcore-plugin-wasm.

plugin_api_version = "1.0"
[plugin]
name = "my-wasm-tool"
version = "0.1.0"
description = "A WASM plugin"
entry = "my-wasm-tool.wasm"
license = "Apache-2.0"
[permissions]
register_tools = true
tool_namespace = "MyWasmTool"
allow_network = false
allow_workspace_read = false
allow_workspace_write = false
allow_tool_invoke = false
permitted_secrets = []
http_allowlist = []
[runtime]
kind = "wasm"

All allow_* flags default to false and permitted_secrets defaults to empty. Omitting them from the manifest has the same effect. The composition root is fail-closed: every capability defaults to the Deny* adapter unless explicitly granted.

Plugin behavior is defined by two WIT worlds in crates/wcore-plugin-wasm/wit/:

wayland-tool (tool.wit): for plugins that register tools. The component imports the shared host interface and exports the tool interface, which exposes:

  • execute(req: request) -> result<response, string>: runs the tool.
  • schema() -> string: returns the JSON Schema for the tool’s input.
  • description() -> string: returns the human-readable description.
  • metadata() -> tool-metadata: returns the full metadata record including name, description, input-schema, category, is-deferred, max-result-size, and caps-version.

wayland-hook (hook.wit): for plugins that intercept lifecycle events. The component imports host and exports the hook interface, which exposes five callbacks: pre-tool-call, post-tool-call, on-session-start, on-session-end, and on-error. The pre-tool-call callback can veto a tool call by returning Err.

Both worlds share the wayland:host/host interface from wayland-host.wit (package wayland:host@1.0.0).

The host interface exposes the following functions to the WASM guest:

FunctionWhat it does
log(level, msg)Emit a structured log entry. level is one of trace, debug, info, warn, error.
now-millis() -> u64Current wall-clock time in milliseconds since Unix epoch.
workspace-read(path) -> result<list<u8>, string>Read a workspace-relative file. Path must not contain .. or a leading /.
workspace-write(path, body) -> result<_, string>Write a workspace-relative file. Same path constraints.
http-request(req) -> result<http-resp, string>Make an outbound HTTP request through the host’s allowlisted client.
secret-exists(name) -> boolCheck whether a named secret is available to this plugin.
tool-invoke(name, input) -> result<string, string>Invoke another registered tool by name.
emit-message(role, text)Emit a message into the host’s conversation log.
is-cancelled() -> boolPoll for cooperative cancellation.

Every capability is fail-closed. Unless the corresponding allow_* or permitted_secrets flag is set in the manifest, the adapter returns a permission-denied error (or false for secret-exists) without calling through to the host.

At load time the runner reads the manifest’s [permissions] block and selects either a Gated* or Deny* adapter for each capability:

Permission flagCapability gated
allow_network = truehttp-request. Also requires a non-empty http_allowlist; an empty allowlist denies all requests even when allow_network = true.
allow_workspace_read = trueworkspace-read
allow_workspace_write = trueworkspace-write
allow_tool_invoke = truetool-invoke
permitted_secrets = ["NAME", …]secret-exists. Only names in the list return true. Secret values are never exposed to WASM. Credentials reach the network only via http-request, where the host injects them.

Setting allow_network = true without populating http_allowlist still results in every request being denied by the GatedHostHttp adapter.

The WASM sandbox enforces isolation at the wasmtime level:

  • Memory cap: 10 MiB cumulative across all linear memories in the component instance (DEFAULT_MEMORY_BYTES). Growth beyond the cap is denied by WasmResourceLimiter.
  • Fuel budget: 500,000,000 fuel units per execution (DEFAULT_FUEL). Execution traps when fuel is exhausted.
  • Epoch timeout: 60 seconds wall-clock per execution (DEFAULT_TIMEOUT_SECS), enforced by a background epoch-ticker thread that increments the engine’s epoch every 500 ms.
  • No threads: the wasmtime engine is configured with wasm threads disabled. Each plugin instance is single-threaded.
  • Secrets never leave the host: secret-exists returns a boolean only. Secret values are injected by the host at the HTTP boundary inside GatedHostHttp, not passed to WASM.
  • Response leak scan: HTTP response bodies are scanned for any byte string matching a permitted secret value before being returned to the guest. Matches are replaced with <REDACTED>.
  • SSRF guard: the HTTP adapter blocks requests to loopback addresses, RFC 1918 ranges, link-local (169.254.x.x), and cloud-metadata hostnames (including metadata.google.internal, metadata.goog, kubernetes.default).
  • Per-plugin workspace isolation: each plugin gets an isolated directory under <user-data-dir>/wayland/plugin-workspace/<sanitized-name>-<hash>, created at 0700. Workspace reads and writes are path-containment-checked; .. traversal and symlink escapes are refused.
  • Fresh instance per call: LoadedWasmPlugin::call_tool builds a new Store<HostState>, a new Linker, and a new component instance for every invocation. There is no shared mutable state between calls.

build_engine() configures wasmtime with:

  • wasm_component_model(true): required for the Tool and Hook WIT worlds.
  • consume_fuel(true): enables the fuel budget knob.
  • epoch_interruption(true): enables the wall-clock timeout knob.
  • async_support(true): required for instantiate_async.
  • debug_info(false): debug info is never included in plugin sandboxes.

The epoch ticker thread is named wcore-plugin-wasm-epoch-ticker and is dropped (and joined) when the WasmPluginRunner is dropped.


A plugin ships a manifest plus the code it registers into the plugin API: providers and tools the engine picks up once installed. The marketplace format defines the registry entry and manifest fields.

For subprocess plugins, implement the JSON-Lines wire protocol described above. For WASM plugins, implement one of the two WIT worlds (wayland-tool or wayland-hook) and compile your component to target wasm32-unknown-unknown or wasm32-wasip2. For the full contract, see the plugin and marketplace authoring reference and docs/marketplace-format.md in the engine repository.