Docs Writing plugins

Writing plugins

Plugins extend Mockarty with new content, logic and interface — a pack of ready
mocks, a custom $.fake.* generator, a panel in the sidebar. This guide takes
you from an empty folder to an installed, working plugin in a few minutes.

If you only want to install a plugin someone else made, see
Plugins. This page is for authors.

Quickstart — create, package, and enable a plugin

For a mock kit, the CLI creates the starter files and packages them. You only
need a running Mockarty instance and an administrator token to install and
enable the bundle.

mockarty-cli plugin create my-plugin        # scaffold (add --template for others)
mockarty-cli plugin pack my-plugin           # → my-plugin-0.1.0.zip
mockarty-cli plugin install my-plugin-0.1.0.zip && mockarty-cli plugin enable my-plugin

The example enables a starter mock kit. Edit its content before sharing it.
For a WASM faker, also run ./build.sh (needs TinyGo).
While iterating, mockarty-cli plugin dev my-plugin --enable re-installs on every
save. Use --template with plugin create to start from another plugin type.

What a plugin is

A plugin is a small .zip bundle with one file that matters — plugin.json, the
manifest — plus any assets it ships (a WebAssembly module, an HTML panel).
The manifest declares what the plugin contributes; Mockarty renders and applies
those contributions itself. The mechanisms:

Mechanism You write Runs where
Mock kit JSON only — (declarative content)
WASM faker a tiny function, compiled to WebAssembly in a locked sandbox
WASM response transformer a tiny function, compiled to WebAssembly in a locked sandbox, inline on the response
WASM request matcher a tiny function, compiled to WebAssembly in a locked sandbox, on the match path
Protocol codec a line-frame encoder/decoder compiled to WebAssembly in a locked sandbox behind the host-owned listener
UI panel (sidebar / page-slot / command / entity-tab / settings) an HTML page in a sandboxed iframe
Wiki macro JSON + a text/template expands on wiki pages, listed in the editor slash-menu
Connector JSON only reuses a built-in integration adapter
Content pack JSON only seeds another module (wiki / dashboards / issues / collections / test cases / contracts) on demand

Plus optional settings (a JSON-Schema config form) on any of them.
You can upload the bundle from a file without a registry connection.

The authoring loop

Everything below uses mockarty-cli. Scaffold, package and dev-reload work fully
offline; only install/enable need a running server and an admin token.

mockarty-cli plugin create my-plugin                 # scaffold (mock-kit template)
mockarty-cli plugin pack my-plugin                   # → my-plugin-0.1.0.zip
mockarty-cli plugin install my-plugin-0.1.0.zip      # upload to a server (admin)
mockarty-cli plugin enable my-plugin                 # turn it on

Pick a template

create scaffolds a package that already validates and passes conformance, so
the first thing you do is edit content, not fight a schema:

--template Scaffolds
mock-kit (default) a pack of ready mocks
wasm-faker a $.fake.* generator + a build.sh (needs TinyGo)
protocol-codec a client-first line protocol + WASM codec + build.sh
ui-panel a sidebar panel — an HTML page in a sandboxed iframe
connector a connection type that reuses a built-in integration adapter
content-pack seed content for another module (wiki, dashboards, issues, …)
link-type a link type between entities, with a deep-link URL template
event-type an event other modules can subscribe to
event-listener a subscription to host events, plus an event of your own
task-type a task type a runner can execute
wiki-macro a macro that expands on wiki pages
agent a specialist agent persona the agent network can route work to
skill a reusable agent recipe the host hands to an agent on demand
mcp-tool your runner task exposed to agents as a first-class MCP tool
workflow-component your runner task, selectable as a mission step
aqc-surface a custom quality surface plus the processor that fills it

Every contribution family the server understands has a template here, with one
exception: a worker ships real executables, and its manifest pins each
artifact’s sha256 — a scaffold cannot invent a binary. Start a worker from the
task-type template and add the workers block by hand.

Check it before anyone else sees it

plugin test runs everything the server would check at install time, plus the
things a successful install still leaves wrong — a contribution with no title
nobody can find in a picker, a permission the manifest asks for but nothing
uses, a settings field named api_token that would hold the token in plaintext.
It needs no server and no network:

mockarty-cli plugin test ./my-plugin                 # a directory or a packed .zip
mockarty-cli plugin test ./my-plugin --strict        # fail on warnings too (use this in CI)
mockarty-cli plugin test ./my-plugin --report r.json # portable report for a reviewer
mockarty-cli plugin test ./my-plugin --report r.json --sign-key key.b64  # signed

Every finding carries its fix on the next line. Errors fail the command, so a CI
job that runs this cannot publish a package the host would refuse.

Set MOCKARTY_CONFORMANCE_HOST_VERSION to also check the manifest’s declared
version range against a specific host; without it that check is skipped rather
than guessed.

Editor completion and CI validation

plugin schema prints the manifest JSON Schema — generated from the same
vocabularies the server enforces, so it cannot drift from them:

mockarty-cli plugin schema --out plugin.schema.json

Point your editor at it ("$schema": "./plugin.schema.json" in plugin.json)
and you get completion and inline errors while you type.

While iterating, skip the manual pack/install cycle — let dev watch and reload:

mockarty-cli plugin dev my-plugin --enable           # re-installs on every save

Validate a bundle any time, no server needed:

mockarty-cli plugin inspect my-plugin-0.1.0.zip

The manifest

{
  "id": "acme.ru-fakers",
  "name": "Russian fakers",
  "version": "1.0.0",
  "description": "What this plugin adds.",
  "author": { "name": "Acme", "url": "https://acme.example" },
  "license": "MIT",
  "min_mockarty_version": "2.0.0",
  "contributes": { }
}
  • id — a lowercase, dot-namespaceable slug (acme.ru-fakers). It is the
    plugin’s permanent identity; pick it once.
  • version — semver. Installing a higher version over a lower one is an
    in-place upgrade that keeps the enabled state.
  • min_mockarty_version — optional; refuse to install on older servers.
  • contributes — the payload. One or more of the sections below.

Validation is strict and the error messages name the exact field, so a typo
fails at pack/install, never silently.

Mock kit (no code)

The simplest plugin: a ready-made contour of mocks. Once enabled, the kit shows
up in the mock-kits catalogue, ready to instantiate into any namespace.

{
  "contributes": {
    "mock_kits": [
      {
        "key": "demo_users_api",
        "name": "Users API (demo)",
        "description": "A starter /users contour.",
        "mocks": [
          { "route": "/users", "method": "GET", "status_code": 200,
            "body": { "users": [ { "id": "$.fake.UUID", "name": "$.fake.Name" } ] } },
          { "route": "/users", "method": "POST", "status_code": 201,
            "body": { "id": "$.fake.UUID", "created": true } }
        ]
      }
    ]
  }
}

Bodies support the same dynamic helpers as any mock ($.fake.*, JSONPath).
Scaffold one with mockarty-cli plugin create my-kit --template mock-kit.

A kit mock can do everything a hand-made mock can

A kit is only worth shipping if the contour it stands up is complete — otherwise
whoever instantiates it has to hand-build the interesting half. So a kit mock
takes the same fields a mock takes, with the same names:

Field What it gives you
conditions Several mocks on one route, told apart by the request body. This is how a kit ships a normal reply, an error and a streamed reply on the same endpoint.
header_conditions, query_conditions The same, matched on headers or query parameters. Query conditions are HTTP-only — a tool call carries arguments, not a query string.
headers Response headers — Retry-After on a 429, a custom trace id. Declaring any header means you own them all; the application/json default is not re-added.
delay Milliseconds before the response (up to 120000). A slow upstream; works for HTTP and for MCP tools.
priority Chooses between mocks whose conditions matched: the higher value wins. A mock with matching conditions still outranks a catch-all mock.
llm A provider-shaped chat reply — see below.
sse An explicit server-sent-event chain, for non-LLM streams. The mock is served to event-stream clients on its own route.
protocol + mcp Serve the mock as an MCP tool instead of a URL.

Among mocks whose conditions matched, the highest priority wins; specificity
breaks ties at the same priority. A mock without conditions is the catch-all and
is used only when no conditioned mock matched. Write the general case without
conditions, each special case with conditions, and use priority to order
overlapping special cases explicitly.

{
  "mocks": [
    { "route": "/v1/chat/completions", "method": "POST", "status_code": 429,
      "priority": 90,
      "conditions": [ { "path": "$.model", "assertAction": "equals", "value": "mock/rate-limit" } ],
      "headers": { "Content-Type": ["application/json"], "Retry-After": ["2"] },
      "body": { "error": { "message": "Rate limit reached.", "code": "rate_limit_exceeded" } } },

    { "route": "/v1/chat/completions", "method": "POST", "status_code": 200,
      "llm": { "provider": "openai", "model": "gpt-4o-mini",
               "content": "You said: $.req.lastUserMessage",
               "finishReason": "stop", "chunkChars": 6, "chunkDelayMs": 25 } }
  ]
}

LLM replies (llm)

provider picks the wire format — openai (also correct for DeepSeek, Qwen,
Mistral, Ollama and other OpenAI-compatible APIs) or anthropic. Streaming
needs no second mock:
when the request sets "stream": true the same mock is
streamed token by token, with chunkChars and chunkDelayMs controlling the
pace. toolCalls returns a function call instead of text, and finishReason
must use the provider’s own vocabulary (stop/tool_calls/length for OpenAI,
end_turn/tool_use/max_tokens for Anthropic) — clients switch on the exact
string.

MCP tools (protocol: "mcp")

Set protocol to "mcp" and the mock answers a tool call rather than a URL.
mcp.tool is the name a client sees, mcp.description is what a calling model
reads to decide whether to use it, and mcp.input_schema is the JSON Schema
of its arguments
— declare it. Without it the server can only infer a schema
from your conditions, and that inferred schema has no required and flattens
nested arguments, which is exactly what a model needs to call the tool
correctly. Set mcp_is_error: true on a variant to return a tool error.

{
  "protocol": "mcp",
  "mcp": {
    "tool": "browser_navigate",
    "description": "Navigate the browser to a URL and return the page title.",
    "input_schema": {
      "type": "object",
      "properties": { "url": { "type": "string", "description": "Absolute URL to open" } },
      "required": ["url"]
    }
  },
  "body": { "content": [ { "type": "text", "text": "Navigated to $.req.url" } ] }
}

Clients reach the mocked MCP server at POST /stubs/<namespace> and discover the
tools with tools/list. Ready-made examples of both kinds live in
examples/plugins/ — llm-openai, llm-anthropic and mcp-playwright,
mcp-github and friends.

WASM faker (custom $.fake.*)

A faker plugin adds new dynamic-response generators — for example valid national
identifiers. The logic runs as a WebAssembly module in a locked sandbox: it
can read and write only its own memory — no disk, no network, no clock. That is
the whole security story: a code plugin cannot touch anything without being
granted it.

Scaffold one:

mockarty-cli plugin create my-fakers --template wasm-faker

You get a manifest, a TinyGo source and a build script:

{
  "contributes": {
    "wasm": [
      { "point": "faker-provider", "module": "faker.wasm",
        "fn": "faker", "exports": ["my_id"] }
    ]
  }
}
  • module — the compiled .wasm asset in the bundle.
  • fn — the exported function the host calls.
  • exports — the faker names this function serves. Listing my_id makes
    $.fake.my_id available in any mock body.

The guest contract

Mockarty passes the requested faker name in and expects a value back, as small
JSON strings. Your module exports three things:

memory                          your linear memory
mk_alloc(size i32) -> i32       a bump allocator returning a pointer
<fn>(ptr i32, len i32) -> i64   packs (outPtr << 32 | outLen) of the result

For the faker point the input is {"name":"<faker>"} and the output is
{"value":"<string>"}. One function serves every name in exports; branch on
the name field. The scaffold implements all of this — you edit only the value
you return.

Build it

The product needs no toolchain to run a plugin — it carries its own WebAssembly
runtime. You need TinyGo only to compile the module:

cd my-fakers && ./build.sh        # tinygo build -target=wasm-unknown -o faker.wasm .
mockarty-cli plugin pack .

A worked example with valid check digits (ИНН/СНИЛС/ОГРН/КПП) ships in the
product’s examples/plugins/ru-fakers — a good starting point for real
generators.

Randomness: a sandboxed module has no entropy source, so seed a small PRNG
that advances per call. Values then vary call-to-call (never cached) but are
deterministic per server start — ideal for reproducible test data.

WASM response transformer (rewrite a mock’s body)

A response-transformer plugin post-processes a mock’s rendered body just before
it is sent — masking a field, wrapping an envelope, redacting a token. It runs as
a WebAssembly module in the same locked sandbox as a faker, inline on the
response path, and only for the mocks you opt in by tag.

{
  "contributes": {
    "wasm": [
      { "point": "response-transformer", "module": "transform.wasm",
        "fn": "transform", "exports": ["envelope"] }
    ]
  }
}
  • point — response-transformer.
  • fn — the exported function the host calls with the body bytes.
  • exports — the transformer keys this function serves. Listing envelope
    lets a mock opt in with the tag transform:envelope.

Opt in per mock. Add a tag transform:<key> to any mock. Its rendered body
is piped through the transformer bound at <key>; untagged mocks pay nothing.
Several transform: tags chain in order.

The guest contract

Same three exports as a faker, but the payload is the raw body, not JSON:

memory                          your linear memory
mk_alloc(size i32) -> i32       a bump allocator returning a pointer
transform(ptr i32, len i32) -> i64   packs (outPtr << 32 | outLen) of the new body

transform receives the body bytes at (ptr,len) and returns the transformed
body. The host caps the input it will hand you (1 MiB by default) — a larger body
is served unchanged rather than truncated, so size your input arena for that cap.
A transformer that errors or traps is skipped and the original body is sent — it
can never break a response.

A worked example that wraps any body as {"data":<body>,"transformedBy":"plugin"}
ships in examples/plugins/response-transformer.

WASM request matcher (custom condition operator)

A matcher plugin adds a new condition operator you can use anywhere a mock
matches a request — a check the built-in operators (equals, contains, matches,
gt/lt, one_of, …) don’t cover, like a Luhn checksum or a national-ID check digit.
It runs as a WebAssembly module in the same locked sandbox, and only for the
conditions that name it — every built-in operator is decided without ever calling
your module.

{
  "contributes": {
    "wasm": [
      { "point": "condition-matcher", "module": "match.wasm",
        "fn": "match", "exports": ["luhn", "divisible_by"] }
    ]
  }
}
  • point — condition-matcher.
  • exports — the operator keys this function serves. Listing luhn lets a
    condition use assertAction: "plugin:luhn".

Use it on a mock. Set a condition’s assertAction to plugin:<key>. Its
path selects the value to check; its value is the operand (for operators that
take one, like divisible_by). A condition whose matcher isn’t installed — or is
disabled for the namespace — simply never matches (the safe default).

The guest contract

Same three exports as a faker. The host passes the invoked key plus the value and
operand as JSON, and expects a boolean:

memory                          your linear memory
mk_alloc(size i32) -> i32       a bump allocator returning a pointer
match(ptr i32, len i32) -> i64  packs (outPtr << 32 | outLen) of the result

Input is {"key":"<key>","actual":"<value>","expected":"<operand>"}; output is
{"match":true|false}. One function serves every key in exports; branch on the
key field. A matcher that errors or traps counts as no-match — it can never
crash request matching.

A worked example (plugin:luhn, plugin:divisible_by) ships in
examples/plugins/condition-matcher.

Protocol codec (client-first TCP lines)

A protocol plugin adds a safe codec, not a network server. Mockarty owns the
listener, namespace routing, 1 MiB frame limit, Socket-mock resolution, request
logs, undefined-request capture and connection shutdown. The guest only maps a
newline-delimited frame to a JSON message and maps the selected mock response
back to one frame.

mockarty-cli plugin create my-codec --template protocol-codec
./my-codec/build.sh                    # needs TinyGo
mockarty-cli plugin test ./my-codec

The manifest binds one descriptor to exactly one WASM export with the same key:

{
  "contributes": {
    "protocols": [{
      "key": "acme_line", "name": "ACME line", "transport": "tcp-line",
      "magic": "ACME "
    }],
    "wasm": [{
      "point": "protocol-codec", "module": "codec.wasm",
      "fn": "codec", "exports": ["acme_line"]
    }]
  }
}

The host calls codec with
{"operation":"decode|encode","protocol":"acme_line","frameBase64":"..."}.
For decode, return {"message":{...}}; for encode, return
{"frameBase64":"..."}. The host overwrites serverName and
pluginProtocol, so guest code cannot route into another protocol or erase
attribution. Calls have a 100 ms deadline and no disk, network, environment or
clock access.

Start Mockarty with MOCKARTY_UNIFIED_PORT=1, enable the plugin, then run
mockarty-cli plugin protocols. Create a Socket mock with the returned
serverName and the event emitted by decode; the Constructor’s Socket section
can fill that routing value from the active-plugin picker. Every resolved call
uses the ordinary request log, while unmatched decoded messages enter Undefined
Requests and can be turned into a mock.

This first transport is deliberately bounded: client-first, printable ASCII
magic (2–16 bytes), UTF-8-safe JSON message, newline framing, one request/reply
stream. Binary framing, server-first handshakes and arbitrary socket state need
a new host transport; they cannot be smuggled into guest WASM.

UI panel (a page in the sidebar)

A UI plugin adds a sidebar item that opens your own HTML page. The page renders
in a sandboxed iframe served from your bundle: strict CSP, no same-origin,
so it can never read the host session or page. It talks to Mockarty only through
postMessage.

mockarty-cli plugin create my-panel --template ui-panel
{
  "contributes": {
    "ui": [
      { "point": "sidebar", "id": "my_panel", "title": "My panel",
        "icon": "wrench-screwdriver", "panel": "panel.html" }
    ]
  }
}
  • icon — a heroicon name (without the hi- prefix). Pick one that exists or
    the slot renders blank.
  • panel — a bundle-relative HTML file.

Mockarty appends the current theme as ?theme=dark|light so your panel can match
light and dark. The scaffold panel.html is theme-aware and self-contained —
edit it into your interface. It follows theme toggles live.

Embedding a panel inline (page-slot)

Besides the sidebar, a ui contribution can render its panel inline on a host
page by using point: "page-slot" with a target naming a host slot:

{ "point": "page-slot", "id": "ops_slot", "title": "Ops", "target": "dashboard", "panel": "panel.html" }

The host exposes UI points beyond the sidebar, all using the same sandboxed
iframe: page-slot (a panel in a page region — target is the page id;
live on 12 screens incl. dashboard, mocks, tasks, test-cases);
command (a Ctrl-K palette action that opens your panel, reachable from any
screen); entity-tab (a tab in an entity card — target is the entity type:
issue renders next to Comments/History, mock next to Config/Logs/Versions/Info,
case inside the test-case builder next to Steps…History; plugin tabs carry a
small puzzle mark so they’re distinguishable from built-in tabs); and
settings-section (a sandboxed companion panel inside the plugin’s
Configure modal, below the host-generated JSON-Schema form). A plugin can
contribute several points from one panel.html (see
examples/plugins/ops-panel).

Adding a diagram format to boards (board-import)

A board-import contribution puts your format into the board import picker,
next to Mermaid, draw.io and board scenes, with a Plugin badge. target is
the format slug:

{ "point": "board-import", "id": "plantuml", "title": "PlantUML",
  "target": "plantuml", "icon": "document-text", "panel": "import.html" }

When the user picks your entry, the host loads panel.html in the usual
sandbox — invisibly, as a converter — and sends it the user’s input:

window.addEventListener('message', (e) => {
  if (e.data && e.data.type === 'mockarty:board-import-request') {
    const mermaid = convert(e.data.text);            // your conversion
    parent.postMessage({ type: 'mockarty:board-import',
                         format: 'mermaid', payload: mermaid }, '*');
  }
});

Answer with format set to mermaid, drawio or scene and payload as the
text in that format; the host converts it into shapes itself. To report a
problem, answer { type: 'mockarty:board-import', error: 'why' } — the user
sees the reason. A panel that does not answer within a minute is dropped.

External panel (panel_url) — bring your own service

Instead of a bundled panel, a ui contribution can point at a page your own
service hosts — set panel_url to an absolute https URL (one of panel /
panel_url is required, never both):

{ "point": "sidebar", "id": "status_board", "title": "Status board",
  "panel_url": "https://plugin.example.com/board" }

The page loads in the same sandboxed iframe as a bundled panel — it gets the
?theme= hint but no host session and no same-origin access. Use this when the
panel is a living product with its own backend (dashboards, SaaS consoles):
nothing to re-bundle on every release, the URL always serves your latest UI.
Users see the panel’s origin in the manifest before installing.

Wiki macro (Confluence-style, no code)

A wiki-macro plugin adds a new !plugin-<name>:… block to every wiki page and
to the editor’s / slash-menu — the Confluence macro gesture, declaratively:

"contributes": { "wiki_macros": [
  { "name": "plugin-status", "title": "Status lozenge", "icon": "tag",
    "params": [ { "name": "colour", "title": "Colour", "default": "grey" } ],
    "template": "<span data-macro=\"plugin-status\" data-macro-args=\"{{.colour}}\">{{.text}}</span>" } ] }

Usage on a page: !plugin-status:colour=green SHIPPED. Arguments are
key=value pairs (quote values with spaces: key="a b"); leftover words
arrive as {{.text}}. The plugin- name prefix is mandatory, so a plugin
macro can never shadow a built-in.

  • template is a Go text/template. Its output re-enters the normal
    markdown pipeline — the Markdown renderer, then the sanitizer — so a macro can produce
    nothing an author couldn’t type by hand; scripts and inline styles are
    stripped. Output is capped at 64 KiB. A template that does not parse fails
    at INSTALL, never at page view.
  • The pinned attribute pair data-macro / data-macro-args (on div/span)
    is allowed through the sanitizer so the host can style macro output — status
    lozenges get colours out of the box (grey/green/red/yellow/blue by the first
    args token).
  • Per-namespace activation applies: where the plugin is disabled, the macro
    line stays as authored text — visible degradation, never silent loss.
  • Enabled macros appear automatically in the editor’s slash-menu with a 🧩
    marker.

Worked example: examples/plugins/wiki-status-macros (a status lozenge and a
DECISION panel).

Connector (a plugin for an external tool)

A connector plugin extends Mockarty toward an external tool — Jira, GitHub,
GitLab, TestRail — with no code. It ships a pre-configured binding onto an
integration adapter Mockarty already carries, so installing it gives you a
ready-to-use connector with sensible defaults.

mockarty-cli plugin create my-jira --template connector
{
  "contributes": {
    "connectors": [
      { "key": "jira_cloud", "name": "Jira Cloud", "kind": "jira",
        "icon": "bug-ant",
        "config_template": { "base_url": "https://your-org.atlassian.net", "project_key": "QA" } }
    ]
  }
}
  • kind — a built-in adapter: jira, github, gitlab, testrail, and
    more. It is validated at install; an unknown kind is refused, because a plugin
    reuses a ready adapter rather than shipping one.
  • config_template — defaults that pre-fill the integration when a user
    creates it from this connector. Never put secrets here — credentials are
    entered at setup.

Enabled connectors are listed at GET /api/v1/plugin-connectors and in the
Plugins UI; creating an integration from one uses its kind and
config_template as defaults, and the actual calls run through the built-in
adapter. This is the pattern for any tracker/CI/chat: reuse a ready adapter,
curate a connector, distribute it as a plugin. The examples/plugins/jira-connector
example is a complete, working starting point.

Settings (a configuration form)

A plugin can declare a settings schema — a JSON Schema of its configurable
options. When present, the admin Plugins page shows a Configure form
generated from the schema, and saved values are validated against it before they
persist.

{
  "id": "acme.ops",
  "name": "Ops",
  "version": "1.0.0",
  "settings_schema": {
    "type": "object",
    "properties": {
      "heading": { "type": "string", "title": "Panel heading" },
      "refresh_seconds": { "type": "integer", "minimum": 1, "maximum": 60 },
      "show_clock": { "type": "boolean", "title": "Show the live clock" }
    }
  },
  "contributes": { "ui": [
    { "point": "sidebar", "id": "ops", "title": "Ops", "panel": "panel.html" },
    { "point": "settings-section", "id": "ops_settings", "title": "Ops preview",
      "icon": "wrench-screwdriver", "panel": "panel.html" }
  ] }
}

The form supports string, number/integer (with minimum/maximum), boolean and
enum (dropdown) properties, plus title and description. The admin form
saves the plugin’s instance default; a namespace override, when present, wins
for that namespace. Both survive plugin upgrades.

A settings-section requires settings_schema and exactly one of a bundled
panel or an HTTPS panel_url; it has no target. It is mounted only while the
plugin is active in the current namespace, carries a visible Plugin badge,
and runs in the same strict sandbox as every other plugin panel. The section is
a companion interface — the host-generated form remains the only place that
saves values.

Never store secrets in settings. Settings are readable by any user who can
open the plugin (the host hands them to the panel), so treat them as public
configuration — URLs, labels, toggles. Credentials belong in a secret store,
not here.

Reading settings in a panel. A sandboxed panel can’t fetch its own settings
(it has no authenticated origin), so the host reads them and pushes them in via
postMessage when the panel loads. A bundled panel receives the values effective
for the current namespace. An external panel_url must declare the
ui:external-panel permission; without that reviewed permission, settings are
not handed to the external origin. Opt in by listening for the message:

window.addEventListener('message', function (ev) {
  if (ev.source !== window.parent) return; // only trust the host
  if (!ev.data || ev.data.type !== 'mockarty:settings') return;
  var s = ev.data.settings || {};
  if (s.heading) document.querySelector('h1').textContent = s.heading;
});

The examples/plugins/ops-panel example does exactly this.

Signing (optional, for regulated environments)

A server can require plugins to be signed by a key its operators trust. Generate
a key pair, sign a bundle, and hand the public key to the server operator:

mockarty-cli plugin keygen --out mykey                # mykey (private), mykey.pub
mockarty-cli plugin pack my-plugin --sign mykey       # signed bundle

The operator configures which keys to trust (and whether signatures are
mandatory). An unsigned or untrusted bundle is then refused on such servers.
mockarty-cli plugin inspect reports a bundle’s signature status.

Content pack — seed another module (no code)

A content pack ships ready-made content for a module beyond mocking and lets a
user instantiate it on demand into a namespace. Today the wiki module is wired
(a Confluence-style starter: runbooks, ADRs, PRDs); dashboards and collections
follow the same shape. Nothing is created until someone instantiates the pack, so
enabling the plugin never touches a namespace’s data.

"contributes": { "content_packs": [
  { "key": "team_wiki", "name": "Team wiki starter", "kind": "wiki",
    "description": "Runbook, ADR and PRD pages.",
    "items": [
      {"title": "Incident runbook", "content": "# Incident runbook\n\n## Summary\n…",
       "children": [{"title": "Postmortem", "content": "# Postmortem\n…"}]},
      {"title": "ADR", "content": "# ADR-000: <title>\n\n## Context\n## Decision\n"}
    ] } ] }
  • kind — which module the pack targets: wiki (pages, nested via children)
    or dashboard (a widget dashboard: items is [{name, description, widgets:[{widgetType, dataSourceId, x, y, w, h, params?}]}]), or issue (a starter project + issues: items is [{name, keyPrefix, description?, issues:[{title, type, description?, priority?}]}], type ∈ task/story/bug/epic), or collection (API-Tester collections: items is [{name, protocol?, requests:[{name, method?, url, body?}]}]), or tcm (test cases with manual steps: items is [{name, priority?, expectedResult?, tags?, steps:[{action, expectedResult?}]}]), or contract (starter API contracts published into the registry: items is [{serviceName, specContent, specType?, version?, description?, tags?}] — spec type is auto-detected when omitted), or board (whiteboard templates: items is [{name, description?, tags?, scene?}] where scene is plain Excalidraw JSON — see examples/plugins/board-templates). Packs that target a licensed module (API Tester, TCM, contract testing, whiteboards) require that feature on the namespace.
  • items — kind-specific content (for wiki, a tree of {title, content, children}).

After install + enable, the pack appears in the content-pack catalogue
(GET /api/v1/content-packs, or the content_pack_list MCP tool) for any
namespace where the plugin is active; instantiate it with
POST /api/v1/content-packs/<key>/instantiate (or content_pack_instantiate).
Example: examples/plugins/team-wiki-pack.

A link contribution declares a typed relation between a Mockarty entity and
another system — “this issue has a Jira ticket”, “this mock came from a GitHub
issue”. The URL template turns a stored key into a clickable deep link (every
{key} is replaced), so a linked entity is one click from its counterpart.

"contributes": { "links": [
  { "key": "jira_ticket", "name": "Jira ticket", "icon": "bug-ant",
    "from_entity": "issue", "to_entity": "external",
    "url_template": "https://your-org.atlassian.net/browse/{key}" } ] }
  • from_entity / to_entity — one of mock, issue, case, wiki,
    collection, dashboard, contract, scan, external.
  • url_template — optional absolute http(s) URL; {key} is the stored value.

Once enabled, the types appear in GET /api/v1/entity-link-types (filter with
?entity=issue) for any namespace where the plugin is active, and entity cards
(e.g. an issue) show an External links section to add/remove stored links
(POST/GET/DELETE /api/v1/entity-links; MCP entity_link_*). Example:
examples/plugins/tracker-links.

Event types — extend the notification catalogue (no code)

An event-type contribution adds new events users can subscribe to per channel
exactly like built-in ones (email, Telegram, Slack, in-app …). They appear in the
notification catalogue under the plugin category.

"contributes": { "event_types": [
  { "type": "plugin.deploy.started",  "title": "Deploy started",  "severity": "info" },
  { "type": "plugin.deploy.failed",   "title": "Deploy failed",   "severity": "error",
    "description": "A deployment failed and needs attention." } ] }
  • type — must be namespaced plugin.<name>, so it can never shadow a
    built-in event.
  • severity — info (default), success, warning, error or critical.
  • default_subscribed — opt users in by default (rare).

Example: examples/plugins/deploy-events.

Emitting events at runtime

A plugin surface with an API credential — a managed worker, an external
process, a panel through the host page — publishes a declared event with:

curl -X POST "https://your-mockarty/api/v1/plugin-events/<plugin-id>/emit" \
  -H "Authorization: Bearer <api-token>" -H "Content-Type: application/json" \
  -d '{"eventType":"plugin.deploy.failed","correlationId":"deploy-42",
       "payload":{"stage":"migrate"}}'

Only event types the manifest declares are accepted, only while the plugin is
installed and active in the namespace, and the payload must be a JSON object
(up to 256 KiB). The host stamps the envelope version, the plugin id and the
optional correlationId/causationId lineage into the delivered payload —
subscribers receive them under those exact keys.

Task types — dispatchable jobs for external runners (no code)

A task-type contribution names a new runner job kind. Pair it with an
external runner that advertises the matching capability (the standard runner
protocol), and submitted tasks of that type are dispatched to it — how a vendor
ships heavy, out-of-process functionality.

"contributes": { "task_types": [
  { "type": "plugin-dbt-run", "name": "dbt run",
    "description": "Execute a dbt model run on an external runner." } ] }
  • type — a lowercase slug namespaced plugin-<name>, max 19 chars, so it can
    never shadow a built-in task type. Plugin task types are always licence-free —
    reachability is governed by the plugin’s own enable state.

Example: examples/plugins/custom-runner-tasks.

Import from another tool (WireMock / Postman / Mockoon / Bruno / Insomnia / Connect / n8n)

Already have a mock library in another tool? Turn it into a Mockarty plugin with
one command — the shared “route + method + status + JSON body” shape converts
directly into a mock kit:

mockarty-cli plugin import ./mappings      --from wiremock --id acme.legacy-stubs
mockarty-cli plugin import ./collection.json --from postman  --id acme.api
mockarty-cli plugin import ./env.json        --from mockoon  --id acme.env
mockarty-cli plugin import ./my-collection   --from bruno    --id acme.api
mockarty-cli plugin import ./export.json     --from insomnia --id acme.api
  • WireMock — a single mapping file, a {"mappings":[…]} file, or a folder of
    per-mapping .json files. jsonBody/body and urlPath/url are handled.
  • Postman — a v2.1 collection; nested folders are walked and each request’s
    first saved example response becomes the mock. {{var}} path variables become
    :var.
  • Mockoon — an environment export; each route’s default (or first) response
    is used.
  • Bruno — a collection folder of .bru files (pass the folder). Each
    request becomes a 200 mock; a body:json block becomes the mock body, and
    {{var}} path segments become :var params. Bruno stores no response
    examples, so responses start as the request body or empty — edit after import.
  • Insomnia — an export v4 file, JSON or YAML flavour (Application menu →
    Export). Requests become 200 mocks (JSON request bodies carried over);
    {{ _.base_url }} prefixes are stripped, in-path templates become :params.

The command writes a ready-to-review plugin directory; then pack and install
it like any other. Bodies that are a JSON object pass through unchanged; a
top-level array or scalar is wrapped as {"response": …} (a kit body is an
object).

Atlassian Connect apps (--from connect)

A Connect app is already “descriptor + your hosted service + iframe pages +
webhooks” — the same shape as a Mockarty plugin with external panels. Convert
the descriptor as-is:

mockarty-cli plugin import ./atlassian-connect.json --from connect
  • generalPages / adminPages / webSections become sidebar panels,
    webPanels become dashboard page-slot panels — all opening your existing
    https service
    via panel_url (no backend changes; --id is optional, it
    derives from the Connect key).
  • webhooks become subscribable plugin event types; scopes are recorded as
    informational permissions shown at install time.
  • Not carried over: the JWT handshake, workflow modules, and {context.param}
    query placeholders (queries are dropped from panel URLs) — the panel runs in
    Mockarty’s sandbox, not in Jira’s context protocol. The descriptor’s baseUrl
    must be a real https URL (templated dev values like {{localBaseUrl}} are
    refused with a clear error).

Review the generated plugin.json, then pack and install as usual.

n8n community nodes (--from n8n)

n8n’s node library is a huge catalogue of integration definitions. Point the
converter at a folder of nodes (a community node package, or a checkout of
n8n-nodes-base/nodes subfolders) and it emits ONE connector plugin — every
node becomes an integration preset:

mockarty-cli plugin import ./nodes --from n8n
  • Nodes matching a built-in Mockarty adapter (github, gitlab, jira,
    jenkins, linear) reuse that adapter; everything else becomes a
    webhook_generic preset, seeded with the node’s declared API base URL when
    its source carries one.
  • The service name, description, category and documentation link carry over;
    trigger variants fold into their base node.
  • Node execution logic and credential flows do NOT carry over — a preset gives
    you the service’s identity and API base so the integration is created in two
    clicks, not a workflow runtime.

--id is optional (default n8n.connectors). Review, pack, install.

Publishing to a registry

There is no central store to gatekeep you. A registry is just a Git repository
with an index.json listing plugins and pointing at their downloadable zips —
the same model as a Homebrew tap. Prepare your entry:

mockarty-cli plugin publish my-plugin-1.0.0.zip \
  --download-url https://github.com/you/registry/releases/download/my-plugin-v1.0.0/my-plugin-1.0.0.zip

Add the printed object to the registry’s index.json, upload the zip as the
release asset, and it becomes installable by id on any server pointed at that
registry. A seed index.json and the example plugins ship in the product’s
examples/plugins/ folder.

Server vs desktop

A plugin behaves identically on a full server and on the desktop build — the
bundle is stored the same way and applied the same way. WASM fakers and UI panels
work in both. On a cluster, enabling a plugin on one node propagates to the others
automatically. Nothing in a plugin is desktop- or server-specific.

Checklist

  • plugin.json valid (mockarty-cli plugin inspect)
  • One clear contributes section per thing you add
  • WASM: .wasm built and packed; fakers listed in exports
  • UI: icon exists; panel is theme-aware; no assumption of host DOM access
  • Semver bumped on every change you ship