# 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`](plugin-api-v1.xtp.yaml), and the canonical Rust implementation is [`gitlab-link/`](gitlab-link/README.md). Embedded manifests have their own generated [`manifest.schema.json`](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`](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`](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: ```json { "$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..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 `/`. 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.`: ```yaml 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: ```yaml 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: ```text 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: ```json { "kind": "http", "reason": "Fetch titles for copied links." } ``` The user grants the same hostname patterns accepted by Extism's `allowed_hosts` policy: ```yaml 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: ```json { "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: ```json { "api_version": 1, "settings": { "token": "resolved value" }, "granted_capabilities": { "env_expansion": true } } ``` `settings` may be `null` when the user has no plugin configuration. Response: ```json { "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: ```json { "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: ```json { "rule_type": "human-readable-link", "settings": { "hosts": ["example.com"] } } ``` Success response: ```json { "result": "ok", "rule": { "hosts": ["example.com"] } } ``` Validation failure: ```json { "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: ```json { "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: ```json { "action": "no-match" } ``` Replace with plain text: ```json { "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: ```sh rustup target add wasm32-wasip1 just build-example-plugin just test-plugins ``` Install a built `*.wasm` file into the directory printed by: ```sh 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: ```sh clipboard-transformer plugin list clipboard-transformer plugin inspect clipboard-transformer plugin doctor clipboard-transformer plugin example 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.