All docs

Plugin docs

Plugin manifest and package format

The cuttle-plugin.json reference: contribution points, variables, the package format and its limits, and validation errors.

Status: v1 (plugin API 1.0.0). The code that enforces this page is packages/types/src/plugins.ts (validatePluginManifest, imported as @ccc/types/plugins). The server's import route, cuttle plugins validate and cuttle plugins pack all run that one function, so an author sees exactly the errors the server raises. When this page and that file disagree, the file wins and this page is the bug.

Related: Permissions (the catalog), Security model.

Package: .cuttleplugin

A .cuttleplugin is a zip archive with cuttle-plugin.json at its root.

acme-suite-1.2.0.cuttleplugin
├── cuttle-plugin.json      required
├── README.md               optional — rendered on the plugin page (sanitized markdown, no raw HTML)
├── CHANGELOG.md            optional — rendered on the Changelog tab
├── icon.png                optional — referenced by "icon"
├── skills/…  agents/…  pipelines/…  mcp/…  host/…

Limits

Checked while streaming, on actual inflated bytes (never the sizes the zip headers claim). Constants: PACKAGE_LIMITS.

LimitValue
Package (compressed)20 MB
Total uncompressed60 MB
Entries2,000
Any single file15 MB
Compression ratio (whole archive)100 : 1
cuttle-plugin.json256 KB

Rejected entries

The whole package is refused, and nothing is left on disk, if any entry:

  • has an absolute path, a drive letter (C:), a backslash, a NUL, a . or .. segment, or an empty segment (a//b);
  • is a symlink, device or other special file (zip external attributes);
  • collides with another entry after Unicode NFC normalization and case folding;
  • is encrypted.

Extraction writes into a temp dir under the plugins root, then renames it into place atomically and makes its files read-only.

The import answers one code per refusal (@ccc/types/plugin-package, PackageErrorCode): package_too_large (HTTP 413), bad_zip (not a zip, ZIP64 or multi-part, corrupt or overlapping entries, a local header that disagrees with the central directory, a size that lies), too_many_entries, unsafe_path, special_file, encrypted_entry, duplicate_entry (including a file inside a path that is itself a file), unsupported_compression (only stored and deflate), file_too_large, uncompressed_too_large, ratio_exceeded, manifest_too_large, missing_manifest, bad_manifest_json, and invalid_manifest with the validator's errors. Inflation is bounded by the tightest remaining budget, so a bomb is stopped after at most that many bytes, whatever its headers claim.

Identity and integrity

The SHA-256 of the package bytes is its identity. It is recorded at import, shown as a prefix on the install sheet, re-verified against the stored file on every boot, and compared against any sha that arrives with the package (the sync doc, the Browse index, an export file). A mismatch is refused as tampered. Packages are unsigned in v1, so every plugin shows Unverified publisher; signing arrives with the store (see the roadmap in Overview).

cuttle-plugin.json

manifestVersion: 1. Objects are strict: an unknown key is a validation error (a typo such as contirbutes fails instead of silently doing nothing).

Complete example

{
  "manifestVersion": 1,
  "id": "acme.suite",
  "version": "1.2.0",
  "displayName": "Automation Suite",
  "description": "Curated automations for repo hygiene",
  "publisher": "acme",
  "icon": "icon.png",
  "license": "MIT",
  "repository": "https://github.com/acme/cuttle-suite",
  "engines": { "cuttle": "^1.0.0" },
  "categories": ["automation"],
  "permissions": {
    "required": [
      { "id": "automations.contribute", "reason": "Adds scheduled automations" },
      { "id": "pipelines.contribute",   "reason": "Adds the PR review pipeline" },
      { "id": "webhooks.receive",       "reason": "GitHub calls the PR review automation" },
      { "id": "skills.contribute",      "reason": "Triage playbook" },
      { "id": "agents.contribute",      "reason": "Triage agent" },
      { "id": "sessions.spawn", "reason": "Automations run agent sessions",
        "cap": { "runners": ["__pty__"], "tools": { "deny": ["Bash"] } } },
      { "id": "providers.use:__pty__",  "reason": "Runs on Claude Code" },
      { "id": "mcp.contribute",         "reason": "ComfyUI tools" },
      { "id": "process.spawn",          "reason": "The MCP server is a local program" }
    ],
    "optional": [
      { "id": "network:discord.com",        "reason": "Post results to Discord" },
      { "id": "network:${config:comfyUrl}", "reason": "Test connection button" }
    ]
  },
  "contributes": {
    "configuration": { "properties": {
      "nightlyTriage": { "type": "boolean", "default": false, "title": "Nightly repo triage" },
      "triageCron":    { "type": "string", "default": "0 2 * * *", "format": "cron" },
      "comfyUrl":      { "type": "string", "format": "url", "default": "http://127.0.0.1:8188", "scope": "machine" },
      "apiToken":      { "type": "string", "secret": true }
    } },
    "skills": [{ "name": "suite-triage", "path": "skills/triage.md" }],
    "agents": [{ "key": "suite-triager", "path": "agents/triager.json" }],
    "automations": [
      { "id": "nightly-triage", "name": "Nightly triage", "type": "cron",
        "schedule": "${config:triageCron}", "enabledWhen": "config.nightlyTriage",
        "agent": "suite-triager", "prompt": "Triage open issues", "runner": "__pty__" },
      { "id": "pr-review", "name": "PR review", "type": "webhook",
        "webhookAuth": "hmac-sha256", "pipeline": "pr-review" }
    ],
    "pipelines": [{ "id": "pr-review", "path": "pipelines/pr-review.json" }],
    "mcpServers": [{ "id": "comfyui", "command": "node", "args": ["${pluginDir}/mcp/server.mjs"],
                     "env": { "COMFY_URL": "${config:comfyUrl}", "COMFY_TOKEN": "${secret:apiToken}" },
                     "attach": "all-sessions" }],
    "host": { "entry": "host/main.js",
              "activationEvents": ["onWebhook:pr-review", "onAction:test-connection"],
              "actions": [{ "id": "test-connection", "title": "Test connection" }] }
  }
}

The unit tests in packages/types/src/plugins.test.ts read this block from this file and fail if it stops validating.

Top-level fields

FieldRequiredRules
manifestVersionyes1
idyes<publisher>.<name>; each part [a-z0-9][a-z0-9-]{1,38}. Globally unique, never changes between versions.
versionyesStrict semver (1.2.0, 1.2.0-beta.1; not v1.2).
displayNameyes≤ 80 chars.
descriptionyes≤ 500 chars.
publisheryesMust equal the part of id before the dot.
engines.cuttleyesA semver range over the plugin API version (below).
iconnoPackage path (PNG or SVG).
license, categoriesnoFree text; up to 10 categories.
repository, homepagenohttps:// URLs only.
permissionsno{ required: [], optional: [] } — see Permissions. Defaults to empty.
contributesnoSee Contribution points. Defaults to empty.

engines.cuttle and the plugin API version

engines.cuttle is checked against PLUGIN_API_VERSION (currently 1.0.0), not against the app's release number. The API version moves only when the plugin surface does: minor for new contribution points or permissions, major for breaking manifest or host-bridge changes. This is the same model as VS Code's engines.vscode.

  • Install refuses a package whose range the running API does not satisfy, and shows the required range next to the running version.
  • If Cuttle is upgraded or downgraded under an installed plugin and the range stops matching, the installation becomes incompatible on that device only; its contributions go inactive and its synced state is untouched.

Permissions

"permissions": {
  "required": [{ "id": "<permission>", "reason": "<why, ≤ 280 chars>", "cap": { … } }],
  "optional": [{ "id": "<permission>", "reason": "…" }]
}
  • id is a permission from Permissions. Three take an argument: providers.use:<runner>|*, secrets.read:<KEY>|* and network:<host[:port]>|*|${config:key}.
  • A permission appears at most once across both lists.
  • Required permissions must all be granted or the install is blocked. Optional ones are off-by-default toggles, changeable later on the plugin's Permissions tab. Grants never widen what the installing user can do.
  • cap is only valid on sessions.spawn. It is the plugin's request; the user can tighten it at install, never loosen it:

    Cap fieldMeaning
    runnersRunner ids the sessions may use (__pty__ = Claude Code terminal, or an ACP runner id from cuttle runners list). Each must also be covered by providers.use.
    modelsModel ids the sessions may use.
    permissionModedefault, acceptEdits or plan. bypassPermissions is not requestable. A session with a mode asks before what that mode asks for, so an unattended automation waits for a person at the first such tool; leave it out for automations and restrict with tools instead.
    tools.allow / tools.denyTool allow/deny lists applied to the main session (Claude Code tool names such as Bash, Write, WebFetch).

Implied permissions

A contribution cannot run without certain permissions, so the validator requires them in permissions.required and rejects the manifest otherwise. Declaring one only as optional also fails. This keeps the install sheet from under-reporting.

ContributionMust declare as required
skillsskills.contribute
agentsagents.contribute
automations (any)automations.contribute, sessions.spawn
a webhook automationwebhooks.receive
an automation with runner: Xproviders.use:X (or providers.use:*)
pipelinespipelines.contribute, sessions.spawn
mcpServersmcp.contribute, process.spawn
runnersrunners.contribute, process.spawn
sessions.spawn cap with runners: [X]providers.use:X (or providers.use:*)

A session a plugin starts without naming a runner uses the user's default runner; if that runner is not covered by a granted providers.use, the run is refused with that reason. It never silently falls through to another runner.

Contribution points

All contribution ids (automations[].id, pipelines[].id, mcpServers[].id, runners[].id, host.actions[].id) match [a-z0-9][a-z0-9-]{0,62}, are unique within their kind, and are local to the plugin. Database rows get deterministic ids derived from (installation, project, kind, local id), so two plugins can reuse a local id and reinstalling revives the same rows (and their run history) instead of duplicating them.

Names that land in shared namespaces carry the plugin's name as a prefix. Agent keys and skill names must start with <name>-, where <name> is the part of id after the dot (acme.suite → suite-triage). Two plugins can still share a <name> under different publishers; the second install is then refused and the conflict named. A project's own agent or skill with the same name shadows the plugin's item (shown with a "shadowed" badge).

configuration

"configuration": { "properties": { "<key>": { "type": "string", … } } }

Keys: letters, digits and _, starting with a letter. The plugin page renders an auto-generated form from this schema.

FieldRules
typestring, number, integer or boolean.
title, descriptionForm label and help text.
defaultMust match type, be in enum, and within minimum/maximum.
enumStrings only: renders a select.
formatStrings only: url (must parse as a URL), cron (a 5-field cron expression) or multiline. Applies to the default and to every saved value.
minimum, maximumNumbers only.
secretStrings only, no default, always user scope. Stored encrypted in Cuttle's secrets store (as the user-scope secret plugin:<pluginId>:<key>), never in plugin settings rows, never synced or exported in plaintext. Referenced only as ${secret:key} in env.
scopeuser (default — synced to your other devices), machine (this device only; for things like a localhost URL), project (a per-project override of the user value).

Stored values for keys a new version no longer declares are kept but ignored. A value that no longer validates after an update falls back to the default and the Settings tab shows a warning.

skills

{ "name": "<name>-…", "path": "skills/x.md" } — a markdown skill, same format as project skills. Injected into sessions of projects where the plugin is enabled, through the same merge as built-in and project skills (built-in → plugin → org → project). Never written into the project's skills/ directory: the session's skills index points at the file in the plugin's read-only extracted tree. Read-only in the UI; Duplicate to customize copies it into the project as your own.

agents

{ "key": "<name>-…", "path": "agents/x.json" } — the JSON is one agent entry in the shape of a value in agents.json, limited to description and prompt (both required), tools, disallowedTools, model, maxTurns, effort and skills. Any other field is rejected at import (bad_agent): hooks and mcpServers run local programs (use contributes.mcpServers), and permissionMode / acpAgent / acpSettings loosen permissions or pick a runner (governed by the sessions.spawn cap and providers.use). Merged as plugin → org → project → repo. Never written into agents.json. Duplicate-to-customize as above.

When skills and agents apply

A plugin's skills and agents reach a session only while all four hold: the plugins flag is on, the installation is active, it is enabled in the session's project, and skills.contribute / agents.contribute is granted. They are read at spawn, so disabling the plugin, removing the project or revoking the grant takes effect on the next session; running sessions keep what they started with. A plugin enabled in a project contributes to every session there, whoever installed it — the same reach as a skill written into the project, which is why members can only enable plugins in projects they can write. The same plugin installed for the org and personally counts once (the org install). Two plugins cannot contribute the same name (the second install is refused); a project's or organization's own item with that name shadows the plugin's.

Where they show. Project → Skills & agents (desktop rail and the phone Skills / Agents sheets) lists them after the project's own items with the plugin's source chip and a shadowed tag when an own item wins; the only action is Duplicate to customize, which copies the item into the project under the same name (so the copy shadows the plugin's) and opens it for editing. Settings → Agents & skills lists every plugin's skills and agents with the number of projects it is enabled in. PUT/DELETE of a plugin skill through the project skill routes answers 409 plugin_managed.

API: GET /api/projects/:id/plugin-contributions ({ skills, agents }, each with source, pluginId, pluginName, shadowedBy: "project" | "org" | null; empty while plugins are off) and POST /api/projects/:id/plugin-contributions/duplicate ({ kind: "skill" | "agent", name, as? }; 409 if the project already has that name). Both are closed to scoped credentials.

automations

FieldRules
id, name, descriptionLocal id, display name, optional description.
typecron, interval or webhook.
schedulecron only, required: a 5-field cron expression, or exactly one ${config:key} naming a string setting.
intervalinterval only, required: `{ "value": <positive int>, "unit": "minutes"\"hours"\"days" }`.
webhookAuthwebhook only: bearer (default; Authorization: Bearer <secret>) or hmac-sha256 (GitHub-style X-Hub-Signature-256: sha256=<hex> over the raw body; X-Cuttle-Signature is also accepted).
enabledWhenconfig.<key> or !config.<key> naming a boolean setting. No expression language. Omitted = on while the plugin is active.
prompt or pipelineExactly one. prompt runs a single agent session; pipeline names a pipeline from this plugin's pipelines.
agentPrompt automations only; a key from this plugin's agents.
runner, modelPrompt automations only. runner needs providers.use:<runner>.

Contributed automations are read-only: they are configured through the plugin's settings, and Duplicate to customize makes an editable copy owned by you. Settings edits update the automation in place — its id, webhook URL and webhook secret survive. The webhook URL and signing mode are shown on the automation (and in GET /api/plugins/:id → items); the secret is never shown again after it is revealed, so Rotate (which invalidates the old secret immediately) is how you get one to paste into the sender.

How they materialize (services/plugins/reconciler.ts):

  • One row per automation per enabled project, with a deterministic id (UUIDv5 of plugin, install scope and owner, project, kind and local id). The reconciler runs after install, settings, project, grant and status changes, uninstall and at boot, and is a no-op when nothing changed.
  • Runs as the user who installed the plugin: they own the automation, and its sessions get a control token naming the installation (see Permissions); CCC_SESSION_TOKEN only with secrets.read for all keys.
  • enabledWhen sets the row's enabled. Disabling the plugin (or it being blocked, incompatible, or plugins turned off) never touches enabled: the scheduler holds the automation, its webhook answers 409, manual runs answer 409 plugin_paused, and lists show Paused: <reason>. Enabling the plugin restores exactly the prior state.
  • Removed from a project, or dropped from the manifest: archived (hidden, never fires, run history kept). Enabling it there again revives the same row, webhook token and secret included.
  • Uninstall remove archives them the same way (installing again revives them); keep makes them yours (source: user, editable, synced) and copies the plugin agents they use, and those agents' plugin skills, into the project. Installing again after keep gives the plugin fresh rows beside your copies.
  • A schedule setting that is not a valid 5-field cron expression keeps the last good schedule and logs a warning on the plugin's timeline.
  • Contributed automations are never synced between devices (each runtime derives them from its installation); a copy you keep syncs like any other.
  • Several installations of one plugin enabled in the same project (an org install and personal ones): one owns the project's rows — active before inactive, org before personal, then the earliest.

hmac-sha256 webhooks need Content-Type: application/json (the signature covers the exact bytes). A GitHub ping event answers 200 pong without running anything. Nested JSON values reach the automation's parameters as JSON strings.

Cron and interval automations fire on one device per user: the one that enabled them. Your other devices show them paused with Runs on <device> · Run here instead (POST /api/plugins/:id/run-here); see Sync and export.

pipelines

{ "id": "…", "path": "pipelines/x.json" } — the JSON is the body the pipeline builder saves: name, optional description, graph: { nodes, edges } and optional parameters. A node needs id, type and config (label defaults to the id, position to the origin); an edge needs id, source and target (condition defaults to success). Import refuses a file that does not parse (bad_pipeline) or whose graph could never run (disconnected, cyclic, unknown node types, …). Materialized as a read-only preset pipeline in each enabled project (runs as the installing user), updated in place on plugin updates, with the same archive / keep rules as automations. A disabled plugin's pipeline starts no new runs (409 plugin_paused); a run in progress finishes.

mcpServers

FieldRules
idLocal id. Injected into sessions under the key plugin-<name>-<id>.
command, argsA stdio MCP server. May use ${pluginDir} and ${config:key}.
envMay use ${pluginDir}, ${config:key} and ${secret:key}.
attach"all-sessions" (default) or { "agents": ["key", …] } to attach only to sessions running those agents.

While the plugin is active, enabled in the project and holds both mcp.contribute and process.spawn, the server is added to every session started in that project — interactive sessions, automations and pipeline nodes, on the terminal and on local provider runners — through the session's own --mcp-config (never a file in the project). One installation per plugin contributes (an org install wins). Disabling the plugin, removing the project from its set, revoking a grant or turning plugins off removes it from the next session; a running session keeps the server it started with.

The server runs as a local process in the session's working directory, so point at plugin files with ${pluginDir} (the read-only extracted tree). ${config:key} resolves to the effective setting for the session's project; ${secret:key} is read from the secrets store at spawn, goes only into the server's environment, and a variable whose secret is unset is left out. It is outside the sandbox — see Security model. Runners on another machine (and Remote Agentic Nodes) skip plugin MCP servers and show the session a notice naming the skipped servers.

runners

{ "id", "name", "command", "args"?, "env"? } — an ACP agent runner. While the plugin is active and holds runners.contribute and process.spawn, it is an approved runtime template plugin-<name>-<id> (with the plugin's source chip) that users create personal connections from, like an admin's custom runtime. Variables as for mcpServers; ${config:key} uses the global setting. An env entry whose value is "" and whose name ends in _API_KEY or _TOKEN is one the user supplies from a personal provider key when creating the connection. Connections always run the plugin's current command (updates move ${pluginDir}), and leave the runner pickers — and refuse to start — while the plugin is not active. Contributing a runner is admin-only: it adds a runner for every user on the server.

host

The sandboxed plugin host runs plugin JavaScript with no ambient authority (see Security model → Plugin host).

FieldRules
entryPackage path to a single ES module (no require, no import of other files in v1; bundle first).
activationEventsonWebhook:<automationId> (a contributed webhook automation: the host receives the payload and returns trigger parameters or drops it) and onAction:<actionId> (a button on the plugin page).
actions{ id, title, description? } for each onAction: event. Every action needs its event and every onAction: event needs an action.

The host module exports handlers by event:

export async function onWebhook(automationId, { headers, body }) {
  if (body.action !== "opened") return null;            // drop
  return { params: { pr: String(body.number) } };         // becomes trigger parameters
}
export async function onAction(actionId) {
  const r = await cuttle.fetch(cuttle.settings.get("comfyUrl") + "/system_stats");
  return { message: r.ok ? "Connected" : `HTTP ${r.status}` };
}

Host code needs the host itself switched on: CUTTLE_PLUGIN_HOST=1 on the server, on top of CUTTLE_PLUGINS=1. With it off, actions answer "plugin code is turned off" and a webhook automation with an onWebhook: handler answers 503 without running (it never falls back to the raw payload).

Handlers. onAction(actionId) runs when someone presses the action's button on the plugin page (or cuttle plugins action <id> <actionId>). Whatever it returns (JSON, ≤ 256 KB) is shown: a string, or an object's message, as the headline and anything else as details. A throw is shown as the failure. onWebhook(automationId, { headers, body }) runs for each verified delivery of that contributed automation (after its Bearer or HMAC check, never before). headers are lower-cased, without authorization, cookie or signature headers; body is the parsed JSON. Return null to drop the delivery (the sender gets 200 {"dropped": true}) or { params } — strings, numbers or booleans — to start the automation with those parameters (202). Anything else, a throw or a limit hit answers 502 and starts nothing.

The cuttle global — the only way out of the sandbox (Security model → The bridge):

CallReturnsNeeds
cuttle.settings.get(key)The effective value of a non-secret setting (synchronous; the project's override for a webhook)—
await cuttle.secrets.get(key)One of its own secret settings, or null when unset—
await cuttle.storage.get(key) / .set(key, value) / .delete(key)Its key/value store: JSON values, keys ≤ 256 characters, 1 MB in total per installation, kept on this device—
await cuttle.fetch(url, { method, headers, body }){ ok, status, headers, url, text(), json() }; body a string ≤ 1 MB; no redirects followed; 20 s timeouta network: grant for that host
await cuttle.api.request(method, path, body?){ ok, status, body } from the Cuttle API, as the plugin's own credentialthe permission that opens the route (Permissions)
cuttle.log(level, …) / console.log(…)Writes to the plugin's timeline (info / warn / error), 20 lines per run—

Limits per run: 5 s of CPU (time spent waiting for cuttle calls does not count), 30 s in total, 64 MB of memory, 20 cuttle calls in flight. Hitting one stops the run and starts the plugin's code afresh next time; module state otherwise lives until the plugin is disabled, updated or the host restarts. There are no timers, require, import, process or fetch globals, and one plugin never sees another's state.

Variables

Only three exist; anything else (${env:HOME}, ${config:a:b}) is a validation error.

VariableWhere it is allowedResolves to
${config:key}schedule, prompt, command, args, env, and network: permission idsThe setting's effective value (project override → user → machine → default). Must name a non-secret setting.
${secret:key}env values onlyThe secret setting's value, resolved at spawn. Never lands in argv, the database or logs: a session whose MCP config carries env gets it as a private (0600) file, removed when the session ends.
${pluginDir}command, args, envThe absolute path of the extracted, read-only package.

Validation error codes

validatePluginManifest(json, { files }) returns { ok: false, errors: [{ code, path, message }] } with a dotted JSON path. Passing files (the package's entry list) also checks that referenced files exist.

CodeMeaning
schemaShape/type/pattern error, including unknown keys.
publisher_mismatchpublisher ≠ the id's first part.
bad_enginesengines.cuttle is not a semver range.
unknown_permissionNot in the catalog, a malformed argument, or a token-only permission (inbox.produce).
duplicate_permissionDeclared twice.
cap_not_allowedcap on a permission other than sessions.spawn.
undeclared_permissionA contribution needs a permission that is not in required.
unsafe_path / missing_filePath escapes the package / is not in it.
duplicate_idTwo contributions of one kind share an id.
name_prefixAgent key or skill name lacks the <name>- prefix.
unknown_variableNot one of the three variables, or used where it is not allowed.
secret_outside_env${secret:} outside env, or a secret setting read via ${config:}.
unknown_config_keyA variable or network: id names no (suitable) setting.
bad_settingA setting's own rules (secret default, enum on a number…).
bad_enabled_whenNot config.<boolean key> / !config.<boolean key>.
bad_automationField not valid for the automation's type, or not exactly one of prompt/pipeline.
unknown_referenceNames an agent, pipeline, webhook automation or action the plugin does not contribute.
bad_agentA contributed agent file is not valid JSON, lacks description/prompt, or carries a field outside the allowed set.
bad_pipelineA contributed pipeline file is not valid JSON, is not the pipeline shape, or (checked by the server at import) its graph could never run.

Package-level errors (limits, rejected entries, sha mismatch) are raised by the import service, not the manifest validator; they are listed in Rejected entries and Limits.