Plugin API v1 #
This document is the human-readable contract for writing Clipboard Transformer
plugins. The generated wire schema is
plugin-api-v1.xtp.yaml, and the canonical Rust
implementation is gitlab-link/. Embedded manifests
have their own generated manifest.schema.json.
Overview #
A plugin is one Extism-compatible core WebAssembly module that:
- embeds a static JSON manifest in a custom section named
clipboard-transformer/manifest; - exports
initialize,compile_rule, andtransform; - accepts and returns JSON for all three exports.
Plugin API v1 supports deliberately narrow text-transform rules. A transform receives the selected UTF-8 value, its semantic alias or exact native kind, and best-effort source application metadata. A matching transform authors plain text, removes stale textual native representations, and retains unrelated native payloads. Plugins neither inspect nor author arbitrary binary or multi-format clipboard content in API v1.
The recommended workflow is to generate language bindings from the XTP schema
and add the Clipboard Transformer manifest layer to the generated project. See
README.md for the generation commands.
Module requirements #
- Format: core WebAssembly module, not a WebAssembly Component.
- Rust target:
wasm32-wasip1, matching the official XTP Rust template. - Maximum module size: 64 MiB.
- Required custom section:
clipboard-transformer/manifest. - Manifest encoding: UTF-8 JSON, at most 256 KiB.
- Required exports:
initialize,compile_rule, andtransform. - Wire encoding:
application/jsonover the Extism call ABI.
Discovery reads and validates the manifest without executing the module. The host instantiates the module only after discovery, configuration loading, and capability resolution succeed.
Manifest #
The manifest describes everything the host must know before it runs the plugin. It is not generated by XTP and must be embedded separately.
Use manifest.schema.json for a complete manifest.
Authored partial inputs used by a build-time assembly pipeline do not need a
$schema; validate the generated manifest instead.
A minimal manifest looks like this:
{
"$schema": "https://raw.githubusercontent.com/jag-k/clipboard-transformer/main/plugins/manifest.schema.json",
"id": "dev.example.links",
"name": "Example Links",
"version": "0.1.0",
"api_version": 1,
"rules": [
{
"type": "human-readable-link",
"name": "Human-readable link",
"description": "Converts a supported URL into a Markdown link.",
"formats": ["text", "url"],
"settings_schema": {
"type": "object",
"properties": {
"hosts": {
"type": "array",
"items": { "type": "string" }
}
}
},
"examples": [
{
"type": "dev.example.links/human-readable-link",
"id": "example-links",
"hosts": ["example.com"]
}
]
}
]
}
Manifest fields #
| Field | Required | Meaning |
|---|---|---|
$schema |
no | Editor hint pointing to the applicable manifest schema. The runtime ignores and does not retain it. |
id |
yes | Stable plugin id. Allowed characters: ASCII letters, digits, ., _, and -. A slash is not allowed. Reverse-domain ids are recommended. |
name |
yes | Non-empty human-readable display name. |
version |
yes | Plugin package version displayed by the host. |
api_version |
yes | Must be 1. A different value is rejected during discovery. |
rules |
yes | Non-empty list of rule types provided by the plugin. Local rule type names must be unique. |
description |
no | Human-readable plugin description. |
author |
no | Author or organization. |
license |
no | License identifier or description. |
homepage |
no | Project homepage. |
settings_schema |
no | JSON Schema describing plugins.<id>.settings. |
capabilities |
no | Host capabilities requested by the plugin. Requests do not grant access by themselves. |
instructions |
no | Setup instructions shown by host tooling. |
Unknown manifest fields are ignored and are not retained in the runtime model.
This permits editor hints such as $schema and forward-compatible producer
metadata without increasing runtime memory. Known fields still have to match
their documented types and validation rules.
Rule descriptors #
Each entry in rules supports:
| Field | Required | Meaning |
|---|---|---|
type |
yes | Local rule type. Its config-facing name is <plugin-id>/<type>. It uses the same allowed characters as a plugin id. |
name |
no | Short display name. |
description |
no | Description used by CLI and generated config schema. |
settings_schema |
no | JSON Schema for rule-specific keys. Prefer a self-contained schema without external or internal $ref values so it can be embedded into the effective config schema. |
examples |
no | Complete example rule objects. The first one is used by clipboard-transformer plugin example. |
formats |
no | Accepted clipboard formats in priority order. An empty list defaults to plain text. |
Common format aliases include text, url, html, and rtf; native format
identifiers are also accepted. A user can override the descriptor's default
order with the rule's formats field.
The schemas improve generated config schema and editor completion; they are
not runtime validation. The plugin must still validate plugin-wide settings in
initialize and rule settings in compile_rule.
Configuration model #
Plugin-wide settings and host-owned permissions live under plugins.<id>:
plugins:
dev.example.links:
permissions:
http:
- example.com
env_expansion: true
settings:
token: ${EXAMPLE_TOKEN}
settings is opaque to the host and is passed to initialize. permissions
is defined and enforced by the host; plugins cannot introduce new permission
keys on their own.
A plugin rule uses its namespaced type:
rules:
- type: dev.example.links/human-readable-link
id: example-links
name: Example links
formats: [url, text]
apps: [Safari]
app_mode: whitelist
hosts: [example.com]
The host consumes these common fields:
type: namespaced plugin rule type;id: required stable rule id;name: optional display name;formats: input format priority override;appsandapp_mode: source-application filter.
Every other key is passed as the opaque settings object to compile_rule.
In the example above the plugin receives {"hosts":["example.com"]}.
Capabilities and sandbox #
Effective capabilities are always:
manifest requests ∩ user configuration grants
Granting something in the user config that the manifest did not request does not grant access; it produces an informational issue instead.
HTTP #
Request HTTP in the manifest:
{
"kind": "http",
"reason": "Fetch titles for copied links."
}
The user grants the same hostname patterns accepted by Extism's
allowed_hosts policy:
permissions:
http:
- api.example.com
- "*.corp.example.com"
Each entry is matched as an Extism glob against the request URL's hostname.
Do not include a scheme, port, path, query, or fragment. Network access is
denied when the list is absent or empty. Use http: ["*"] to allow every
HTTP host.
The host never sends this list to the plugin. Generated bindings expose the
host import http_host_allowed(host) -> bool; call it with one literal
hostname immediately before an Extism HTTP request. The host matches that
candidate against its private policy. Candidate values containing glob
metacharacters are rejected, so a plugin cannot supply its own pattern.
Environment expansion #
Request expansion in the manifest:
{
"kind": "env-expansion",
"reason": "Resolve token references in plugin settings."
}
When the user sets permissions.env_expansion: true, the host expands values
inside plugin-wide settings before initialize. Supported forms are $VAR,
${VAR}, ${VAR:-default}, ${VAR-default}, ${VAR:?message},
${VAR?message}, and $$ for a literal dollar.
The plugin never receives the process environment itself. A failed required expansion blocks the plugin without executing it.
Available runtime facilities #
- WASI clocks and randomness are available.
- No filesystem directories are preopened.
- Extism logging and the Extism variable store are available.
- Extism HTTP is restricted to the effective HTTP allowlist.
http_host_allowed(host) -> boolchecks one literal hostname without revealing the allowlist.- Direct process-environment access is not granted.
Plugin-wide settings deliberately travel through the typed initialize
request instead of Extism config. Extism config is a static string-to-string
map, while plugin settings are structured JSON and need lifecycle validation
and structured issues. Extism vars are the right fit for mutable per-instance
state between calls; their total size is bounded by the host.
Lifecycle and exports #
Calls into one plugin instance are serialized. A config or plugin reload creates a fresh instance and repeats initialization and rule compilation.
1. initialize #
Called once after the host resolves settings and capabilities.
Request:
{
"api_version": 1,
"settings": {
"token": "resolved value"
},
"granted_capabilities": {
"env_expansion": true
}
}
settings may be null when the user has no plugin configuration.
Response:
{
"status": "operational",
"available_rules": ["human-readable-link"],
"issues": []
}
status is one of:
operational: normal operation;degraded: usable with reduced functionality;blocked: no rule providers are registered for this plugin.
available_rules contains local names declared in the manifest. Omit it or
return null to make every declared rule available. Returning an undeclared
name produces an issue and does not register that rule.
Structured issues #
An initialization response may report issues:
{
"code": "token-missing",
"severity": "warning",
"summary": "No API token is configured; offline labels remain available.",
"details": "Set plugins.dev.example.links.settings.token.",
"setting_path": "token",
"rule_types": ["human-readable-link"],
"attention": "informational"
}
| Field | Required | Values or meaning |
|---|---|---|
code |
yes | Stable machine-readable identifier. |
severity |
yes | warning or error. |
summary |
yes | Short user-facing message. |
details |
no | Longer explanation. |
setting_path |
no | Path within plugin-wide settings. |
rule_types |
no | Affected local rule type names. |
attention |
no | none, informational, or action-required; defaults to none. |
Use stable codes, actionable summaries, and action-required only when the
user actually needs to change something.
2. compile_rule #
Called for each configured rule during config loading.
Request:
{
"rule_type": "human-readable-link",
"settings": {
"hosts": ["example.com"]
}
}
Success response:
{
"result": "ok",
"rule": {
"hosts": ["example.com"]
}
}
Validation failure:
{
"result": "error",
"message": "hosts must not contain paths"
}
The successful rule value is opaque to the host and is passed back on every
transform. Keep it JSON-serializable and prefer an object for compatibility
with generated bindings. Returning error skips only that configured rule.
3. transform #
Called when the host finds the first available representation matching the rule's ordered formats.
Request:
{
"rule_type": "human-readable-link",
"rule": {
"hosts": ["example.com"]
},
"format": "url",
"value": "https://example.com/items/123",
"source_app": {
"bundle_id": "com.apple.Safari",
"name": "Safari"
}
}
source_app may be null; both of its fields are independently nullable.
format is normally a portable semantic alias such as text, url, html,
or rtf. A rule may explicitly select an exact native kind when its raw bytes
are valid UTF-8, but the plugin still receives only the decoded value, not
the host's complete native descriptor or payload list.
No match:
{
"action": "no-match"
}
Replace with plain text:
{
"action": "replace",
"text": "[Item 123](https://example.com/items/123)",
"message": "Link converted to Markdown"
}
message is optional notification text. A replacement becomes an authored
plain-text semantic value. Stale text, URL, HTML, and RTF native
representations are removed; unrelated binary and application-specific
representations plus source-application metadata are preserved. Returning the
same text as the selected input is treated as no change by the host.
Resource limits #
The current host defaults are:
| Resource | Limit |
|---|---|
| WASM module | 64 MiB |
| Embedded manifest | 256 KiB |
| Linear memory | 64 MiB |
| One exported call | 5 seconds |
| One HTTP response | 4 MiB |
| Extism variable store | 1 MiB |
| One plugin response payload | 16 MiB |
Treat these as ceilings, not targets. A trap, timeout, malformed JSON response, or oversized response becomes a structured host failure rather than crashing Clipboard Transformer.
State and concurrency #
compile_rule and transform calls for one plugin instance are serialized.
Use the Extism variable store for small instance state created by initialize;
do not rely on filesystem persistence. The host may discard and recreate the
instance on startup, config reload, or plugin reload.
The opaque value returned by compile_rule is the preferred place for
per-rule compiled state. It avoids global lookups and makes reload behavior
predictable.
Build, install, and diagnose #
For the canonical Rust example:
rustup target add wasm32-wasip1
just build-example-plugin
just test-plugins
Install a built *.wasm file into the directory printed by:
clipboard-transformer plugin paths
Discovery reads *.wasm files directly inside that directory in filename
order. If two valid modules declare the same plugin id, the later one is marked
failed instead of replacing the first silently.
Useful diagnostics:
clipboard-transformer plugin list
clipboard-transformer plugin inspect <plugin-id>
clipboard-transformer plugin doctor <plugin-id>
clipboard-transformer plugin example <plugin-id>
clipboard-transformer plugin reload
The desktop runtime also watches the plugin directory and reloads changed modules automatically.
Compatibility checklist #
Before shipping a plugin:
- keep
api_versionat1and use the generated v1 bindings; - keep the plugin id stable across releases;
- declare every rule statically in the embedded manifest;
- request only capabilities the plugin can explain to users;
- validate plugin-wide settings in
initialize; - validate every rule in
compile_rule, not lazily intransform; - return stable issue codes and useful setup instructions;
- make
transformdeterministic when external services are unavailable; - test with missing grants, missing settings, malformed rule settings, and network failures;
- run the plugin through
just test-plugins, not only guest-side unit tests.