git:20261001.6e3237c to git:20261007.5cedc46

27 added, 2 removed. Audit A to A.

---
name: creating-worca-cc-plugins
description: Use when creating, scaffolding, debugging, reviewing, or extending a Worca CC plugin — anything involving worca-cc-plugin.json, a task-source connector, plugin-shipped agents/scripts/skills/workflow templates, the `worca plugin` CLI, or the Plugins view.
---
# Creating Worca CC Plugins
A Worca CC plugin is a **git repo** (or a subdir one level deep) containing `worca-cc-plugin.json`.
It contributes up to six things to the host, ships **no code that runs in the browser**, and — from
plugin API 4 — can ship the **declared forms** its agents ask questions with.
| Dir | Contributes | Executed? |
|---|---|---|
| `connector/*.mjs` (via `taskSources[].module`) | task-source connector | **Yes** — in an ephemeral child process |
| `scripts/<key>.meta.json` + the program it names | pipeline scripts (node / shell / python) | **Yes** — worca spawns the program per execution |
| `agents/<key>.md` + `<key>.meta.json` | pipeline agents (meta v2 sidecar: typed input/output ports; `ask.forms` from API 4) | No — prompt text fed to `claude -p`, forms drawn by the host |
- | `skills/<name>/SKILL.md` | agent skills | No — copied into the run worktree |
+ | `skills/<name>/SKILL.md` | agent skills | No — copied into the run worktree for an agent that requires it; a user can add any of them to a set, and runs and Ask Worca then get it as `/<set>:<name>` |
| `workflows/*.json` | pipeline templates (v2 graph JSON — one Task node, one End node) | No — validated into DB rows |
| `mcpServers` in the manifest (API 5) | MCP servers for worca's registry | **Yes** — a stdio server is started per spawn once a user adds it to a set |
**The connector, the scripts and stdio MCP servers are the executable seams.** Everything else is data.
Internalize this before designing anything.
## Start here — always scaffold
```bash
worca plugin init my-plugin --with task-source,agents,skills,workflows
```
Produces a plugin that already passes `validate --strict`. Edit it; don't hand-roll a manifest.
Drop parts you don't need with `--with` (note: `workflows` requires `agents`).
## Dev loop
```bash
worca plugin link ./my-plugin # current/ -> your working dir (imports workflows/)
WORCA_MOCK=1 worca plugin exec my-plugin main listTasks # offline, canned frames
worca plugin exec my-plugin main getTask --args '{"id":"X-1"}' # real call
worca plugin exec my-plugin main listTasks --inspect # --inspect-brk on the child
worca plugin reimport my-plugin # re-read workflows/*.json after an edit
worca plugin validate ./my-plugin --strict
worca plugin new-script tidy --dir ./my-plugin # scripts/tidy.{meta.json,mjs,tests.json}
worca plugin validate ./my-plugin --run-cases # lint, then RUN every shipped script case
worca script test tidy # one bench run of a linked/installed script
worca script test tidy --all # its saved cases; exit 1 = a case failed
worca plugin doctor my-plugin
```
`link` takes a **dir**, not a name — the name comes from the manifest. Linked plugins refuse `update`.
Edits to a linked dir are live for the connector, agents and skills: no reinstall, the next op spawns
a fresh child. **Pipeline templates are the exception** — they are DB rows, imported once at `link`
(and at `install`/`update`), so after editing `workflows/*.json` run `worca plugin reimport <name>`.
Reimport upserts: a template file you DELETE keeps its row until the plugin is removed.
`plugin list`, `plugin doctor` and the Plugins card name every contribution worca **ignored** —
an agent key that collides with an existing agent, a sidecar that fails the meta v2 gate, a template
that fails graph validation. If your agent or flow is missing, that line says why.
## Manifest quick reference
Only `name` is required. Unknown fields are warnings (errors under `--strict`).
| Field | Notes |
|---|---|
| `name` | kebab-case, ≤64 chars, machine-unique, used as a dir name |
| `version` | optional — absent means the pinned SHA **is** the version |
| `engines.worca-cc-api` | `">=4 <5"` (what `plugin init` writes); `">=5 <6"` to ship MCP servers. Integer host API. Unparseable → fails closed, won't install. Host APIs are a SET ([1, 2, 3, 4, 5]); the highest version your range admits is **negotiated**, and that is what decides which features you get. API 3 adds meta v2 sidecars and v2 graph templates; **API 4 honours an agent's `ask.forms`**; **API 5 honours the manifest's `mcpServers`** |
| `setup.node` | `true` → `npm ci --ignore-scripts --omit=dev` at install. **Lockfile mandatory** |
| `setup.python` | only `"pyproject"` → `uv sync`. Worca CC never *runs* python; your JS spawns it |
| `taskSources[].id` | kebab-case |
| `taskSources[].module` | must start `./`, no `..`, no backslashes |
| `taskSources[].configSchema[]` | persistent config — `text` \| `select`, plus `secret`/`required`/`default`/`help`/`options` |
| `taskSources[].multiProfile` | `true` → one install holds several independent configurations (two Jira servers, two orgs), each with its own config/secrets/state. Users create profiles in the UI and bind each project/workspace to one. Omit it and the source uses a single implicit `default` profile — identical to before profiles existed |
| `taskSources[].inputs[]` | per-run UI — see next table. **Exactly one `task-browser` required** |
| `models[]` | catalog entries: `id`, `label`, `efforts`, `env` (literal or `{"secret": "<key>"}`), `cost` (`{free:true}` \| `{perMtok:{…}}`), and `upstream` for a bridged model — `{provider: "copilot"\|"openai"\|"anthropic", api: "anthropic"\|"openai-chat"\|"openai-responses", model, baseUrl?, apiKey? (a `${VAR}` ref only, never a literal), headers?, capabilities? (toolCalls, vision, reasoning, maxPromptTokens, maxOutputTokens, reasoningEfforts)}`. A `copilot` entry resolves against each user's own sign-in |
| `modelSecrets[]` | `{key, label}` — the secrets `models[].env` may reference; prompted for at install. With the credential broker on, each person adds the key on the key page instead (a per-person `p-<plugin>-<key>` slot): keep `ANTHROPIC_BASE_URL` a literal URL and put the secret only in `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` (docs/credential-broker.md "Plugin slots") |
| `mcpServers` | API 5: name → MCP server definition (`type`, `command`/`args`/`env` or `url`/`headers`, `fields`, `description`) — see **MCP servers (API 5)** |
## Marketplace manifest (repo-level, optional)
A repo can declare itself a **marketplace** with `worca-cc-marketplace.json` at its root:
```json
{ "name": "My Plugins", "description": "…", "plugins": ["plugins/one", "plugins/two"] }
```
Listed dirs (any depth, no `..`) each contain a `worca-cc-plugin.json`; when the file is
present it is the complete plugin list (the depth 0–1 auto-scan is skipped). Repos without
it still work as implicit marketplaces via the scan. The worca-cc repo itself is a
marketplace (its bundled plugins live under `plugins/`), registered by default.
## UI is schema-driven — pick from these widgets
You declare shapes; the host renders them. **No plugin code ever runs in the browser** — not for a
source pane, not for a question form. The two declarative surfaces are the source `inputs[]` below
and an agent's `ask.forms` (see [Ask forms](#ask-forms-api-4)).
| `inputs[].type` | Widget | Requires |
|---|---|---|
| `text` | text box | — |
| `select` | static dropdown | `options[]` |
| `remote-select` | dropdown, loaded on first focus by calling your connector | `optionsFrom` (a connector op name) |
| `task-browser` | debounced search + result list + preview | exactly one per source |
`configSchema[]` supports only `text` and `select`; `secret: true` renders `type=password` and is
never sent back to the browser (arrives redacted as `{set: true}`).
**`remote-select` + `optionsFrom` is the extensibility hatch.** Naming an op there is also what
allowlists it for the browser — undeclared ops are rejected with 400 at `/api/sources/call`.
The data, filtering, and pagination semantics behind every widget are entirely yours.
## Connector contract (unchanged through API 3)
Default-export a **factory**, not a class:
```js
export default function createTaskSource(ctx) {
return { validateConfig, listTasks, getTask, reportResult, capabilities, /* custom ops */ };
}
```
| Op | Signature | Returns |
|---|---|---|
| `validateConfig` | `()` | `{ok:true, identity?}` \| `{ok:false, errors:[{field,message}]}` — gates the whole pane |
| `listTasks` | `({inputs, search, cursor})` | `{tasks:[{id,title,url,state,labels,updatedAt}], cursor?}` |
| `getTask` | `(id)` — **positional** | `{...summary, body /* markdown */, meta}` |
| `reportResult` | `(id, {status, summary, links, inputs})` — **positional** | anything |
| `capabilities` | `({inputs})` — optional | `{writeBack, incrementalSync}`; missing → `writeBack: true` |
| custom | `(args)` | whatever the widget needs |
`ctx` = `{ apiVersion, profile, config, state: {get, set}, log }` — `apiVersion` is the negotiated highest version your range admits.
- `ctx.config` — `configSchema` values, defaults applied, `{"$env":"VAR"}` already resolved
- `ctx.state.get/set` — a KV bag; `set` only *records*, the **host** persists it after a successful frame
- `ctx.profile` — which profile this op runs against (`'default'` unless `multiProfile`). `config` and `state` are *already* that profile's bucket; the id is here so a connector that keeps its own on-disk storage can key it the same way
- `ctx.log(level, msg)` — the only legal output channel
Throw `Object.assign(new Error(msg), { kind })` to pick the error kind:
`auth | rate-limit | network | plugin | timeout | protocol`. The UI renders kind-keyed messages.
## Runtime model — design around it
One `node` child process **per op**. No daemon, no reuse. Env is scrubbed to `{PATH, HOME}` only.
Payload arrives on stdin, one JSON frame leaves on stdout, process exits. 30s timeout → SIGKILL.
## Agents & templates (API 3)
An agent sidecar is meta v2: typed PORTS replace the v1 channel vocabulary.
```json
{
"metaVersion": 2,
"displayName": "My Helper",
"runnerType": "producer",
"inputs": [{ "id": "task", "type": "md", "required": true }],
"outputs": [{ "id": "plan", "type": "md", "filename": "plan.md" }],
"placeable": true
}
```
- `inputs[]`: `{ id, type, required?, loop?, expands?, as?, directive? }`
- `outputs[]`: `{ id, type, when?, filename?, store?, artifactKind? }`
- `runnerType`: `producer` | `verifier` | `clarifier`; a `verifier` also declares `verdict`.
- `humanEffort` (optional): `{ "factor": <number ≥ 0> }` scales the human-hours estimate of this
agent's executions, `{ "hours": <number ≥ 0> }` fixes it per execution; `{ "factor": 0 }` opts out.
Absent → the heuristic (code delta, md/json outputs, verifier reads). Invalid → ignored with a warning.
- `placeable: false` keeps an agent off the composer canvas (off-pipeline scanners).
- `agentFile`: optional; the prompt file for this agent. It is a **plain basename inside
`agents/`** — no `/`, no `..`, never absolute. Defaults to nothing (the scaffold writes
`"<key>.md"`). The install consent card reads the tools of exactly this file.
A workflow template is a v2 graph, and `worca plugin init` scaffolds this shape:
```json
{
"version": 2,
"name": "My Pipeline",
"domain": "coding",
"nodes": [
{ "id": "n_task", "kind": "task", "x": 40, "y": 200, "config": {} },
{ "id": "n_helper", "kind": "agent", "key": "myHelper", "x": 320, "y": 200, "config": {} },
{ "id": "n_end", "kind": "end", "x": 600, "y": 200, "config": {} }
],
"wires": [
{ "id": "w1", "from": { "node": "n_task", "port": "task" }, "to": { "node": "n_helper", "port": "task" } },
{ "id": "w2", "from": { "node": "n_helper", "port": "plan" }, "to": { "node": "n_end", "port": "result" } }
]
}
```
Node ids must match `/^n_[a-z0-9]{1,32}$/`; exactly one `task` node and one `end` node are required.
## Ask forms (API 4)
An agent that needs a human can ship the UI its question is asked with. The form is **JSON in the
sidecar** — the host draws it from a fixed widget catalog, validates the answer, and resumes the
agent. Nothing you write runs in the browser.
Your manifest must **negotiate** API 4 or later (`">=5 <6"` honours forms too) or the block is ignored:
```json
"engines": { "worca-cc-api": ">=4 <5" }
```
A plugin declaring `">=3 <4"` keeps working: its agents load, its ports are intact, and it asks with
generic questions. The ignored block is named in `worca plugin list`, `worca plugin doctor` and the
Plugins card, the same way an ignored sidecar is.
### The declaration
`ask.forms.<id>` in `agents/<key>.meta.json`. This is the form worca itself ships on its code
reviewer — copy its shape:
```json
{
"version": 1,
"title": "Confirm these findings",
"data": {
"type": "object",
"required": ["findings"],
"properties": {
"summary": { "type": "string", "maxLength": 4000 },
"findings": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"required": ["id", "title"],
"properties": {
"id": { "type": "string", "maxLength": 64 },
"severity": { "type": "string", "enum": ["critical", "major", "minor", "suggestion"] },
"title": { "type": "string", "maxLength": 200 },
"detail": { "type": "string", "maxLength": 2000 }
}
}
}
}
},
"answer": {
"type": "object",
"required": ["findings"],
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "verdict"],
"properties": {
"id": { "type": "string" },
"verdict": { "type": "string", "enum": ["keep", "waive"], "default": "keep" },
"note": { "type": "string", "maxLength": 2000 }
}
}
},
"notes": { "type": "string", "maxLength": 4000 }
}
},
"layout": [
{ "widget": "markdown", "bind": "data.summary" },
{ "widget": "review-list", "bind": "data.findings", "field": "findings", "label": "Findings" },
{ "widget": "textarea", "field": "notes", "label": "Notes" }
],
"example": {
"summary": "Two findings I would waive if the deviation was intended.",
"findings": [
{ "id": "f1", "severity": "major", "title": "Plan deviation: the retry lives in the caller", "detail": "The plan put it in the client. Was that intentional?" },
{ "id": "f2", "severity": "minor", "title": "No unit test for the empty-list branch", "detail": "It is covered by the integration test only." }
]
}
}
```
Three parts, three owners: **`data`** is what your agent must supply at run time, **`answer`** is what
the human returns (the contract your prompt reads), **`layout`** is view only.
### What the agent writes at run time
Instead of the legacy `{"questions":[…]}` payload, your agent writes `{form, data}` to the questions
file and stops:
```json
{ "form": "review-findings",
"data": {
"summary": "Two findings I would waive if the deviation was intended.",
"findings": [
{ "id": "f1", "severity": "major", "title": "Plan deviation: the retry lives in the caller",
"detail": "The plan put it in the client. Was that intentional?" }
] } }
```
### What it receives back
On resume (or on a clarifier's `answers` port):
```json
{ "form": "review-findings", "version": 1,
"values": { "findings": [ { "id": "f1", "verdict": "waive", "note": "Deliberate, see ADR-12." } ],
"notes": "" } }
```
`version` is the integer you declared; bump it when the `answer` contract changes so a resumed agent
can tell which shape it is reading.
### Widget catalog (`askCatalog: 1`)
| Class | Widgets |
|---|---|
| Input | `text`, `textarea`, `number`, `slider`, `toggle`, `date`, `select` (radio cards ≤ 6, else dropdown), `multiselect`, `rank`, `table-select`, `review-list`, `gallery` (with `field`) |
| Display | `markdown`, `callout`, `image`, `gallery`, `compare`, `pdf`, `code`, `diff`, `table`, `json`, `file-list`, `media` |
| Layout | `group`, `columns`, `tabs` |
An item may carry `"requires": { "askCatalog": 2 }` plus a `"fallback"` item for a widget a newer
host has; with no fallback the form fails validation. Display widgets contribute nothing to the
answer.
### Layout item keys
An item may carry `widget`, `field`, `bind`, `label`, `help`, `when`, `requires`, `fallback` — plus
its widget's own keys. Anything else is a validation error, so a misspelt `lables` is named instead
of silently ignored.
| Widget | Own keys |
|---|---|
| `text` | `placeholder`, `mono` |
| `textarea` | `placeholder`, `rows` |
| `number` | `unit`, `placeholder` |
| `slider` | `unit`, `minLabel`, `maxLabel` |
| `toggle`, `date`, `markdown`, `pdf`, `media`, `json` | — |
| `select` | `style`, `options`, `labels`, `descriptions`, `tones`, **`suggest`** |
| `multiselect` | `style`, `options`, `labels`, `descriptions` |
| `rank` | `titleKey`, `metaKey` |
| `table-select`, `table` | `columns` |
| `review-list` | `titleKey`, `bodyKey`, `metaKey`, `labels`, `tones`, `notePlaceholder` |
| `gallery` | `captionKey`, `fileKey` |
| `callout` | `text`, `title`, `tone` |
| `image` | `caption` |
| `compare` | `before`, `after`, `beforeLabel`, `afterLabel` |
| `code` | `name`, `lang` |
| `diff` | `name` |
| `file-list` | `fileKey`, `noteKey` |
| `group` | `title`, `children` |
| `columns` | `columns` |
| `tabs` | `tabs` |
`suggest` on a `select` is suggested answers **plus free text**: the field's schema stays a plain
`{ "type": "string" }` with no `enum`, so the value is whatever the human types.
### The dialect
A closed JSON Schema subset: `type` (`string`, `number`, `integer`, `boolean`, `array`, `object`,
`file`), `enum`, `enumFrom`, `default`, `defaultFrom`, `required`, `minimum`, `maximum`,
`multipleOf`, `minLength`, `maxLength`, `pattern`, `patternHint`, `format` (`date`, `date-time`,
`email`, `uri`), `items`, `minItems`, `maxItems`, `uniqueItems`, `properties`, `title`,
`description`, `accept`. Nothing else — no `$ref`, `oneOf`, `anyOf`, `if`/`then`;
`additionalProperties` is always false; nesting stops at object → array → object.
- `file` is **data-side only**: a run-relative path plus `accept` (mime patterns). worca snapshots
the file at ask time, sniffs its real type against your `accept` and a host allowlist, and serves
it by index — never by path. html and anything scriptable is refused.
- An **opaque object** — `{ "type": "object" }` with no `properties` — is data-side only and never at
the root. It is how the `json` display widget shows an arbitrary blob your agent produced. The
answer side stays closed.
- `enumFrom` / `defaultFrom` are **answer-side only** and resolve against your data
(`data.images[].id`), so the human picks from a closed set your agent just produced and a field
like a suggested version number arrives prefilled. `enum` and `enumFrom` are mutually exclusive.
- `patternHint` is the sentence shown when `pattern` fails. Write one: "Does not match the expected
format" tells nobody what a semver field wants.
- `bind`, `enumFrom`, `defaultFrom` and `options.from` use one tiny path language: `data.a.b` and
`data.items[].id`. No expressions, no indexes, no functions.
- `when` is the only conditional: `{ "field": "value" }` or `{ "field": ["a", "b"] }`, equality on
answer fields, and an item may not depend on its own field. A hidden field is dropped from the
answer and is not required while hidden.
- `"surface": "web"` says a text answer is meaningless. Chat then prints the projection and keeps
waiting; the CLI refuses the run rather than pretend. Default is `"any"`.
- `default` prefills the web form **and** feeds `--yes`: with no default, auto mode takes the first
`enum` value, a `rank` takes data order, and a `review-list` takes every item's default verdict.
Pick defaults that are safe to apply unattended.
### Limits
At most 8 forms per agent, and the whole `ask` block stays under 64 KB:
| Rule | Value |
|---|---|
| Form id | `^[a-z][a-z0-9-]{0,47}$` |
| `title` | 1–120 characters, mandatory |
| Forms per agent | 8 |
| `ask` block size | 64 KB |
| Files per ask | 24 |
| Bytes per file / per ask | 25 MB / 100 MB |
### Two gates you will meet
**At load / validate time**, a form must prove itself: `example` is mandatory, it must validate
against `data`, every widget, `field`, `bind` and item key must be legal, every required answer field
must have an input in the layout — and the **auto answer built from `example` must be a valid
answer**. That last rule is the one that surprises authors: a *required*, always-visible free-text
field with no `default` and no `defaultFrom` **fails validation**, because `--yes` could never answer
it. Give it a default, make it optional, or put it behind a `when`.
**At ask time**, worca runs the same check against the data your agent actually wrote. Data that
passes the schema but that an unattended run could not answer — an `enumFrom` that resolved to an
empty list on a required choice, a `defaultFrom` whose source the agent left out — is **refused**,
and your agent is resumed once with the error list so it can supply better data. A second failure
downgrades to a plain free-text question. A run is never crashed by a form.
### Validate before you ship
A form that fails is a **warning** (worca drops it and the agent keeps generic questions) and an
**error** under `--strict`, which is the author's gate:
```bash
worca plugin validate ./my-plugin --strict
```
Errors name the item by its reading position — `ask.forms."review-findings" layout#2: …` — and every
failed rule is listed, not just the first.
The install consent card and `worca plugin install`'s receipt name, per agent, how many forms ship
and which file types they may display from the run folder — on a host that will honour them, so only
when your manifest negotiates API 4 (below that the line is absent and the ignored-contributions line
names the block instead). That list is read from your `accept` patterns alone, before anything is
installed and without running a line of your code — so keep `accept` as narrow as the form really
needs.
+ ## Skills
+
+ A plugin's `skills/<name>/SKILL.md` folders reach agents two ways:
+
+ - **Required by an agent** (`requiresSkills` on its sidecar): copied into the run's checkout.
+ - **Through sets** (`docs/skills.md`): installing adds every skill to the catalog (Settings › Sets › Skills);
+ a user adds one to a set, and every run and Ask Worca turn of that set's projects gets it as
+ `/<set>:<name>` — one generated plugin per set, through `--plugin-dir`. A team policy can require one
+ (`skills.required: [{ "plugin": "acme", "skill": "deploy-checklist" }]`); each developer turns it on.
+
+ For a skill that works from a set:
+
+ - The folder name is the skill name and equals the frontmatter `name`: `^[a-z0-9]+(-[a-z0-9]+)*$`, at most
+ 64 characters, not `synced` or `anthropic-skills`. A skill that breaks a rule is listed invalid and never
+ mounted.
+ - Refer to the skill's own files with `${CLAUDE_SKILL_DIR}`, never `${CLAUDE_PLUGIN_ROOT}`: from a set the
+ skill sits in a generated plugin that holds only `skills/`, so a plugin-root path breaks (the catalog flags it).
+ - At most 300 files, 1000 links and folders, 1 MB per file, 8 MB in all; no `.claude-plugin/` folder inside.
+ - Ask Worca never runs inline shell blocks (`` !`cmd` ``). Pipeline runs DO run them, without guardrail checks:
+ Claude Code runs them when it renders the skill, not as a tool call a guardrail sees — so keep them read-only.
+ A `.sh` script is badged "shell scripts — Windows".
+ - Updates list `new skill: x`; a skill a set holds reads `SKILL CHANGED: x — in <sets>` or
+ `SKILL REMOVED: x — leaves <sets>` (it leaves those sets); uninstalling the plugin takes its skills out of
+ every set.
+
## MCP servers (API 5)
A plugin can ship **MCP server definitions** for worca's own registry — never written into anyone's
Claude Code config. Installing starts nothing: a user adds a server to one or more **sets** in
- Settings › MCP servers, fills in that set's values and secrets, and assigns sets to projects; pipeline
+ Settings › Sets, fills in that set's values and secrets, and assigns sets to projects; pipeline
agents and Ask Worca then get one copy per (set, server). The consent card lists each server with its
command or URL.
Your manifest must **negotiate** API 5 or the block is ignored (and named as an ignored contribution):
```json
"engines": { "worca-cc-api": ">=5 <6" }
```
```json
{
"name": "acme-tools",
"version": "1.4.0",
"engines": { "worca-cc-api": ">=5 <6" },
"mcpServers": {
"sentry": {
"type": "http",
"url": "https://mcp.sentry.dev/mcp",
"headers": { "Authorization": { "field": "token", "prefix": "Bearer " },
"X-Sentry-Org": { "field": "org" } },
"fields": [
{ "key": "token", "label": "Sentry token", "secret": true, "oauth": true, "required": true },
{ "key": "org", "label": "Organization", "required": true }
],
"description": "Sentry issues and events"
},
"jira": {
"type": "stdio", "command": "node", "args": ["./mcp/jira.mjs"],
"env": { "JIRA_URL": { "field": "baseUrl" }, "JIRA_TOKEN": { "field": "token" } },
"fields": [
{ "key": "baseUrl", "label": "Jira URL", "required": true, "default": "https://acme.atlassian.net" },
{ "key": "token", "label": "API token", "secret": true, "required": true }
],
"description": "Search and read Jira issues"
}
}
}
```
Rules — `worca plugin validate` checks every one, and an error blocks the install:
- **Name** (the map key): `^[a-z][a-z0-9-]{0,19}$`; `worca` is reserved; no `_` (copy names use it).
- **`type`**: `stdio` needs `command` (+ `args`, `env`); `http` / `sse` need `url` — `https:`, or `http:`
only for `localhost`, `127.0.0.1`, `::1` — plus optional `headers`.
- **`fields[]`** — what each set fills in: `key` `^[A-Za-z][A-Za-z0-9_]{0,31}$` (unique ignoring case),
`label`, `secret` (default false), `oauth` (secret only: a bearer token the user refreshes by hand),
`required` (default false), `default` (non-secret only).
- **Field refs** `{ "field": "<key>", "prefix"?: "…", "suffix"?: "…" }` go in an `env` value, a
`headers` value, an `args` element, or `url` (a string, one ref, or an array of strings and refs
joined in order). A secret field goes in `env`, `headers` or the path/query of `url` — never in
`args` (argv is readable by every local user), never the whole `url`, its host or userinfo. A field
used in `url` must be `required`. A field behind a secret-sounding env/header key (`TOKEN`, `KEY`,
`SECRET`, `Authorization`, …) or query parameter (`api_key`, `token`, …) must be `secret`.
- **Env keys** `^[A-Za-z_][A-Za-z0-9_]{0,63}$`, unique ignoring case, never starting `MCPSECRET_` or
`MCPCHILD_`. **Header names** are RFC 7230 tokens, unique ignoring case; `Host`, `Content-Length`,
`Transfer-Encoding`, `Connection` and `Upgrade` are refused.
- **No literal secrets.** Every literal (`command`, `args`, `url` parts, env and header values,
`prefix`/`suffix`, `default`, `description`) refuses `${` and control characters; a `prefix` may not
end in `$` and a `suffix` may not start with `{`. A token-shaped literal (`Bearer ghp_…`, a JWT,
`?api_key=…`, a URL with a password) is an error: make it a secret field.
- **Paths**: a `command` or `args` element starting `./` resolves against your plugin dir (the pinned
version; your working dir when linked) and must exist inside it — the `command` as a file, an `args`
element may name a directory; the consent card shows it as `<plugin-dir>/…`. `"command": "node"`
runs worca's own Node. Python: `"command": "uv", "args": ["run", "--directory", "./", "server.py"]`
— `uv run --directory ./`, since no working directory is assumed.
- **`description`**: at most 200 characters (it is flattened into prompts).
A stdio server starts with only a keep-list of the environment (`HOME`, `PATH`, `LANG`, …) plus the
`env` you declare: declare every variable it needs.
**Updates** list each new, removed and changed server. A changed `type`, `command`, `args`, `url`,
`headers` or `env`, a field added or removed, or a field's `default`, `secret` or `required` is a red
line naming the sets that hold secrets for it; a `label`, `description` or `oauth` change is not.
Applying keeps a set's value whose key and `secret` flag survive and drops the rest; a server you
drop, like an uninstall, leaves every set with its values and secrets.
## Scripts (API 3)
A script card runs **your program** instead of spawning Claude: worca hands it the bound
input paths and the allocated output paths in one JSON envelope on stdin and reads one
JSON frame back on stdout. Cost is $0 and the result is repeatable.
| File | Required |
|---|---|
| `scripts/<key>.meta.json` | yes — meta v2, `key` = filename stem |
| `scripts/<key>.mjs` / `.py` / `.sh` (+ `.cmd`) | the program `file` names, per runtime |
| `scripts/<key>.tests.json` | optional — saved test cases that travel with the script |
```bash
worca plugin new-script tidy --runtime node --dir ./my-plugin
```
Scaffolds all three, already valid. `--runtime shell` writes **both** `tidy.sh` and
`tidy.cmd`: on Windows worca runs the `win32` entry under `cmd.exe`, which cannot run a `.sh`.
### The three runtime contracts
`node` — the default export receives one api object and returns a frame:
```js
export default async function ({ inputs, outputs, params, ctx, log }) {
log('info', 'hello from a worca script');
return { summary: 'ok' }; // plus { outputs: { <port>: { value } }, verdict }
}
```
`shell` — the envelope arrives as environment variables, and whatever the command prints
is captured into every md output it did not write itself. Exit `0` is clean, exit `1` is
blocking, anything else is an execution error (`exitCodes` in the sidecar overrides).
```sh
#!/bin/sh
echo "checking $WORCA_IN_PLAN" # WORCA_IN_<PORT>, WORCA_OUT_<PORT>, WORCA_PARAM_<ID>,
# WORCA_VERDICT, WORCA_CWD, WORCA_ENVELOPE
```
`python` — `main(api)`, where `api` exposes `inputs`, `outputs`, `params`, `ctx` and `log`
both as attributes and as keys. Standard library only, python ≥ 3.8.
```python
def main(api):
api.log('info', 'hello from a worca script')
return { 'summary': 'ok' }
```
`stdout` carries the frame in `node` and `python`. `console.*` and `print` are re-routed to
the run log, so they are safe; a direct `process.stdout.write` (or a child process that
inherits fd 1) corrupts the frame — use `log`, or write to stderr.
### Test cases
`<key>.tests.json` ships the proof that the script works:
```json
{ "version": 1,
"cases": [
{ "id": "sample", "name": "sample",
"params": { "command": "npm test" },
"inputs": { "plan": { "text": "# Plan" }, "done": { "fired": true } },
"cwd": { "kind": "scratch" },
"expect": { "verdict": "clean", "fired": ["log", "pass"] } } ] }
```
Inputs are inline text; a void port takes `{ "fired": true }`. A shipped case must use
`"cwd": { "kind": "scratch" }` — a case that names a project folder cannot travel, and is
an install-blocking error. `expect` is optional: without it a case reports its result and
is red only when the script could not run at all.
```bash
worca plugin validate ./my-plugin --run-cases # lint, then run every case. Exit 1 = a case failed.
```
That is the command to put in CI. What each script prints streams to stderr as
`[<key>/<case>] …` lines, and Ctrl+C or a CI cancel stops the running script (exit 2).
A `python` case needs python ≥ 3.8 on the CI host: without one the case FAILS with the
interpreter sentence — it is never skipped.
Keys: a script key shares ONE namespace with agent keys, and a key that a built-in script or
a built-in agent holds (`shell`, `js`, `py`, `planner`, …) is never loaded from a plugin.
`new-script` refuses those, the reserved names (`new`, `bench`, `runtimes`) and the Windows
device names (`con`, `nul`, `com1`, …).
## Common mistakes
| Mistake | What happens |
|---|---|
| `console.log` anywhere in the connector | Corrupts the stdout frame → `PluginOpError('protocol')`. Use `ctx.log` |
| Reading `process.env.MY_TOKEN` | Always undefined — env is `{PATH, HOME}`. Declare a `configSchema` field; users can point it at `{"$env":"MY_TOKEN"}` |
| Zero or two `task-browser` inputs | Install-blocking validation error |
| Calling a custom op from the pane without `optionsFrom` | 400 — the browser allowlist is derived from `inputs[]` |
| `setup.node: true` with no `package-lock.json` | Install fails; `npm ci` requires a lockfile |
| Relying on a `postinstall` script | Never runs — install uses `--ignore-scripts` |
| Assuming `ctx.state.set` persisted after a throw | It doesn't. The delta is discarded unless the op returns `ok` |
| Agent `.md` with no `.meta.json` sidecar | Registry silently ignores it (warn only) |
| Workflow template referencing an agent key you don't ship | Template skipped at import |
| Non-opaque task ids | `getTask(id)` receives exactly what `listTasks` emitted — keep them round-trippable |
| Self-managed storage not keyed by `ctx.profile` | Two profiles share one cache/cookie jar/CLI config dir → one instance silently answers with the other's data. Hang every path you own off the profile id |
| `getTask().body` not markdown | It becomes the pipeline prompt verbatim |
| Symlinks pointing outside the plugin dir | Deleted during export, reported as a warning |
| A v1 sidecar (`consumes`/`produces`/`connectsTo`) | Ignored at load with a Plugins-view note — port it to `metaVersion: 2` |
| A v1 `steps` workflow template | Ignored at load with a Plugins-view note — rewrite it as a graph (`version: 2`) |
| `agentFile` pointing outside `agents/` (`../`, absolute) | Install-blocking validation error; the loader also refuses to read it |
| A template referencing a key whose sidecar was rejected | ONE line naming the sidecar — the template is not re-reported rule by rule |
| An agent key that collides with a built-in or user agent | Yours is dropped; `plugin list`/`doctor`/the Plugins card name it under "contributions ignored" |
| Editing `workflows/*.json` in a linked dir | The DB row is stale until `worca plugin reimport <name>` |
| A shipped case with `"cwd": {"kind": "project"}` | Install-blocking validation error — a shipped case runs in a scratch folder |
| `<key>.tests.json` with no `<key>.meta.json` beside it | Validation error — nothing would ever read it |
| A `shell` script with `<key>.sh` and no `win32` entry | Runs on macOS and Linux only — on Windows the file goes to `cmd.exe`, which cannot run a `.sh`. Ship the `.cmd` half |
| A node/python script that writes no value for a declared output | Execution error `output "<port>" was not written` |
| A script key a built-in script or agent already holds (`shell`, `planner`, …) | Yours is dropped at load; `worca plugin new-script` refuses the key up front |
| `process.stdout.write` in a `node` script | Corrupts the result frame. `console.*` is safe — it is routed to the run log |
| An `ask` block on a plugin declaring API 3 | The block is ignored, the agent asks with generic questions; `plugin list`/`doctor`/the Plugins card say so. Declare `">=4 <5"` |
| A form with no `example` | Validation error — `example` is what proves the declaration and what the Agents view previews |
| A `type:'file'` with a wide `accept` | Every reviewer sees it on the consent card. Narrow it to what the form actually shows |
| `mcpServers` on a plugin declaring API 4 | The block is ignored — no server reaches the catalog; `plugin list`/`doctor`/the Plugins card say so. Declare `">=5 <6"` |
| A secret field referenced from `args` | Validation error — argv is readable by every local user. Pass it through `env` |
## The example worth reading
`plugins/github-source/` — a complete, zero-dependency GitHub Issues source:
ETag revalidation via `ctx.state`, `remote-select` repo picker, a filter micro-syntax,
`{"$env":"GH_TOKEN"}` token config, and write-back that comments and optionally closes the issue.
Copy its shape for a plain HTTP-API source.
Patterns it doesn't show, for connectors that need them: with `multiProfile`, key any
self-managed on-disk storage by `ctx.profile`. When wrapping an **external CLI** as the
backend, keep a single subprocess seam with error-kind mapping, detach anything interactive
(e.g. a browser login) so it outlives the 30s op timeout, and pin the CLI's config via an
env var so nothing depends on the server's cwd. For **per-run write-back**, add a
`writeBack` select to `inputs` (default `no`); the host pins the run's inputs on the row and
passes them to `capabilities({inputs})` / `reportResult`, so only runs that opted in are
reported. The key name `writeBack` is the host's reserved opt-out channel: when the
`capabilities` probe errors, the host fails CLOSED (no report until retry) only for runs
that pinned a `writeBack` input — every other run keeps the fail-open default rather than
silently dropping the tracker comment on a transient blip.
## Where the host logic lives
| Concern | File |
|---|---|
| Manifest schema + validation | `src/core/plugin-manifest.mjs` |
| Install / update / uninstall lifecycle | `src/core/plugin-store.mjs` |
| Script registry + the runtime contract | `src/core/script-registry.mjs`, `src/core/graph/script-runner.mjs` |
| Script store + the test bench | `src/core/script-store.mjs`, `src/core/script-bench.mjs` |
| Shipped script cases (`validate --run-cases`) | `src/core/plugin-script-cases.mjs` |
| Child-process protocol | `src/core/plugin-shim.mjs`, `plugin-shim-child.mjs` |
| Config / secrets / state (per-profile buckets) | `src/core/plugin-config.mjs` |
| Profile → project/workspace binding resolution | `src/core/source-bindings.mjs` |
| Widget rendering | `ui/public/source-pane.mjs`, `ui/public/plugins-view.mjs` |
| CLI | `src/cli/worca-cc.mjs` (`worca plugin help`) |
| Ask-form dialect, the three gates (`validateFormDef`, `checkAskData`, `collectAnswer`) and the text projection | `src/shared/forms/` |
| Ask-form rendering (host-owned widgets) | `ui/public/ask/form-renderer.mjs` |
| MCP server rules, catalog, plugin lifecycle | `src/core/mcp/definitions.mjs`, `src/core/mcp/catalog.mjs`, `src/core/mcp/plugin-lifecycle.mjs` |
## Before you ship
```bash
worca plugin validate ./my-plugin --strict && worca plugin doctor my-plugin
```
Both clean, plus at least one real (non-mock) `exec` per op.
Then push and let users register the repo as a marketplace:
`worca marketplace add <repo-url>` (or Plugins → Add marketplace in the UI) →
install from the Available list or `worca plugin install <name>`.
Removing a marketplace never removes installed plugins.
Installs are SHA-pinned; users see a consent inventory listing every agent's tools, every ask form it
can put on screen with the file types that form may display, every MCP server with its command or URL,
and every secret you request — so keep all four minimal.