A native desktop app that quietly cleans copied text and links using rules you control

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:

  1. embeds a static JSON manifest in a custom section named clipboard-transformer/manifest;
  2. exports initialize, compile_rule, and transform;
  3. 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, and transform.
  • Wire encoding: application/json over 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;
  • apps and app_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) -> bool checks 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_version at 1 and 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 in transform;
  • return stable issue codes and useful setup instructions;
  • make transform deterministic 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.