Plugin contract
A plugin is a folder (or a repo subdirectory) with a plugin.toml at its root. How to add
one to the board is in Load a plugin; why the code-running
parts need a separate approval is in Plugin trust.
Contract
Section titled “Contract”Each enabled folder has a plugin.toml at its root. API version 1 is declarative apart
from two deliberately gated exceptions: a [[hooks]] table and a [[tools]] table both
contribute Python that runs in the board’s runner process, and only from a source a human
has explicitly trusted (see Trusted hooks and
Agent tools). Providers, integrations and Loop nodes remain outside this
first contract.
[plugin]id = "team-review"name = "Team code review"version = "1.0.0"api_version = 1requires = []
[[commands]]id = "team-code-review"label = "Team code review"description = "Review a branch with the team rubric"instructions = "SKILL.md"default_model = "claude-sonnet-4-6"default_thinking_level = "high"report_ui = "review-report"
[[ui]]id = "review-report"slot = "ticket_report"title = "Review report"description = "Plugin rendered review findings"asset = "ui/report.html"capabilities = ["ticket.summary", "ticket.review_findings"]
[[actions]]id = "review"slot = "ticket_toolbar_action"label = "Review"description = "Run the team code review on this ticket"command = "team-code-review"Identifiers and contribution keys must be unique. Dependencies must exist and form no cycle. Instruction and HTML paths must resolve to files inside the enabled root. The loader rejects the whole bundle before registration when any declaration is invalid. Manifests and instruction files are capped at 256 KiB, while self contained HTML may be up to 5 MiB.
Commands
Section titled “Commands”A command contribution appears in the normal skill picker. A command ticket still uses
the existing worktree, model, permission, verification, and review flow. The board puts
the external instructions content directly into the run prompt rather than asking a
vendor CLI to resolve an unknown slash command. Optional model and thinking defaults apply
only when the ticket has no explicit choice.
UI surfaces
Section titled “UI surfaces”A visual surface declares what it DOES. There is no slot to pick and no board change to wait for: the properties below compose, so the next presentation need is a manifest edit.
| Property | Values | Meaning |
|---|---|---|
mount | always (default), on_demand | On screen with the board, or opened by a human and closable again |
chrome | none (default), window | A bare transparent frame, or a board drawn title bar with a close button |
placement | anchor (default), free | Parked where the board puts it, or dragged wherever the human wants it |
position | bottom_right (default), bottom_left, top_right, top_left | Which corner an anchored surface sits in, and which corner a free one opens at |
size | small, medium (default), large | |
min_size | { width, height }, default { width = 240, height = 200 } | The floor a resize clamps to |
resizable | boolean, default true for placement = "free" | Whether the window offers a corner grip |
pointer | pass_through (default), capture | Whether taps reach the board underneath |
launcher | none (default), app_tile | Whether App Space shows a tile that opens it |
render | iframe (default), markup | A sandboxed frame that RUNS the asset, or sanitized markup the board draws in its own DOM |
[[ui]]id = "snake"title = "Snake"asset = "ui/snake.html"mount = "on_demand"chrome = "window"placement = "free"launcher = "app_tile"A pass_through surface takes no pointer input, so taps reach the board; the host still
posts each pointer down to the frame as {type: "rev0:plugin-pointer", version, x, y} in frame coordinates, which is how a pet reacts to a tap without turning its box
into a dead zone. Nothing about the board travels with it. An always mounted surface
floats above the ticket modal and below menus, dialogs and toasts. Set html, body { background: transparent } and do not declare color-scheme: the host pins those frames
to the default scheme because Chrome paints a sandboxed frame opaque when its scheme
differs from the embedder, and the board root is dark.
The parser refuses combinations that describe a surface nobody could use, naming both halves and the way out: a window that is always mounted can never be closed, an on demand surface with no chrome is never drawn, a free surface with no chrome has nothing to drag, a launcher for an always mounted surface opens what is already open, and only a free surface can be resized.
Markup surfaces
Section titled “Markup surfaces”render = "markup" asks the board to draw the asset NATIVELY, in its own DOM, instead of
loading it into a sandboxed frame. The surface then inherits the board’s theme and needs
no frame boundary, at the price of running no code at all: the asset is sanitized against
a fixed allow-list, at both ends, and everything executable is removed. iframe stays the
default and is unchanged, so anything that needs to RUN keeps working exactly as before.
[[ui]]id = "sprint-notes"title = "Sprint notes"asset = "ui/notes.html"mount = "on_demand"chrome = "window"render = "markup"Kept: structure, text, lists, tables, <details>, <button>, <a href> (http, https or
mailto, forced into a new tab), class / title / role / aria-label / data-action,
and scoped <style> elements. Removed: <script> and every on* handler, javascript:
and data: URLs, <form>, <iframe>, <svg>, id and name, the style attribute,
raw-text elements and their contents (<title>, <textarea>, <noscript>, <xmp>), and
anything that fetches while rendering: <img>, <link>, media, and url() /
@import / image-set() inside a <style>. A <style> block is dropped WHOLE if it
contains any of those, or anything that is not tree-scoped: :host, @property, @page,
@view-transition. A <button> is always type="button", so it can never act as a
submit control.
The asset is sanitized when the board SERVES it, so the URL itself returns the clean
value; the browser sanitizes it again on the way into a closed shadow root. A markup asset
is capped at 256 KiB (not the 5 MiB an iframe asset may be) and a bigger one fails at
LOAD time rather than rendering as an empty surface. One asset file cannot back both an
iframe surface and a markup one (they share a URL, and a URL is served one way), so
give the markup surface a copy of its own. render = "markup" is also refused on a
ticket_report or a region surface: those are handed board data over a message port,
and markup has no script to receive it with.
Clicks inside a plugin’s markup do nothing. A plugin declares behaviour through
[[actions]], where the board renders the control itself; markup is for a surface that
only needs to be seen.
Forward incompatibility, by design. [[ui]] keys are a strict allow-list, so a
manifest using render will NOT load on a board older than this feature: it fails with
ui[0] has unknown keys: render rather than quietly rendering an iframe. That is the same
trade every additive key here makes, and it is deliberate: a bundle that names a shape the
board cannot draw should say so at load time. If a bundle must support both, ship the
render key only in a version that requires this board or newer. The plugin API stays at
api_version = 1; it grows additively and is not bumped for a new key.
ticket_report is NOT part of this vocabulary and stays a real slot. It is data bearing,
ticket scoped and capability filtered behind a board owned outer host, which is the one
boundary here that is load bearing. It appears on command tickets selected by
match_command or a command’s report_ui reference, and takes no presentation
properties.
Host regions
Section titled “Host regions”A surface above places ITSELF: it floats over the board, or opens as a window. A surface
with a region is placed by the host instead, in a named part of the board’s own UI:
[[ui]]id = "sprint-load"title = "Sprint load"asset = "ui/load.html"region = "board_header"size = "small"capabilities = ["ticket.list"]| Region | Scope | Where it renders | What it can see |
|---|---|---|---|
board_header | board | A compact strip above the board, in every view | The board being viewed, so board scoped capabilities only |
ticket_panel | ticket | A section inside the ticket modal | That ticket and its board, so every capability |
The scope is the whole rule. A ticket scoped capability needs an open ticket to be
projected from, and board_header has none, so declaring one there fails the bundle with
a message naming both halves and pointing at the region that would work:
ui[0] declares the ticket scoped capability 'ticket.summary' in region 'board_header',which is board scoped and has no open ticket to read it from: declare it in a ticketscoped region instead (ticket_panel)A region name the board does not have fails the bundle too, rather than mounting nowhere, because a surface that silently never appears is indistinguishable from a broken one.
The host owns placement, styling and accessibility, so a region surface declares no
presentation of its own: mount, chrome, placement, pointer, launcher, render,
resizable, min_size and position are all refused next to a region, as is
match_command (a region is drawn wherever the host draws it, not selected by the command
a ticket ran) and slot = "ticket_report" (that is the OTHER board owned host, so naming
it here asks to be placed twice). size is the one thing left to declare: small,
medium or large picks how tall the board draws the frame, and small is what makes a
header strip a strip.
A region surface is served through exactly the same board owned host, sandbox and
capability bridge as a ticket_report, and like one it may declare no network: a
surface handed board data must not also have somewhere to POST it. A region is a new
place, deliberately not a new data path. The two board owned hosts now refuse the same
presentation keys through the same check, position included.
A bundle that declares a region surface AND [[hooks]] is all or nothing: an untrusted
one fails entirely, so its region stays empty rather than rendering a panel that could be
read as “its hooks were approved”. See Trusted hooks below.
Regions come from one table, REGIONS in rev0/external_plugins/surface.py.
Adding one is that entry plus one <PluginRegion name="..." /> mount point in the
frontend; tests/test_plugin_regions.py fails until the client’s PluginRegionName union
and the table above agree with it. Removing a source, unloading a plugin, deleting its
folder or breaking its manifest empties its regions on the next catalog refresh, because
the frontend derives them from the live catalog rather than holding on to a surface.
Every surface is an iframe with scripts allowed and no same origin permission. Plugin HTML cannot read board cookies, browser storage, the React tree, or board APIs. Asset routes serve only exact files declared by currently enabled plugins and apply an enforced sandbox policy even when opened directly.
Declared network hosts
Section titled “Declared network hosts”A surface reaches nothing by default. network lists the exact origins its frame may
fetch from, which is what makes a docker monitor, a pod view or a network visualiser
useful rather than merely drawable:
[[ui]]id = "containers"title = "Containers"asset = "ui/containers.html"network = ["http://localhost:2375", "https://k8s.internal:6443"]Each entry is an exact origin, scheme://host[:port], with a scheme of http, https,
ws or wss. Wildcards, paths, trailing slashes, userinfo, CSP keywords such as
'self', and bare hostnames are all refused, and one bad entry fails the whole bundle
like any other invalid declaration. A ticket_report may declare no network at all: it
is the one surface the board hands real ticket data to, so a host it could POST to would
be an exfiltration route rather than a fetch.
The grant is per surface and moves ONE directive. That surface’s connect-src becomes
the list it declared; every other surface, in the same bundle or another, keeps
connect-src 'none'. default-src, frame-src, worker-src, object-src,
form-action and navigate-to stay 'none', and the frame keeps sandbox allow-scripts with no same origin, no cookies and no storage. (Two surfaces that declare
the same asset file share one URL and therefore one response, so that response carries
the union of what they declared.)
The honest boundary: the frame still holds no board data and cannot reach any board API, so the worst case is a plugin talking to hosts you listed. That is a real grant, not a formality, so declared hosts appear on the plugin’s card in Settings and in the approval panel when an agent asks to load the source.
Data bearing ticket reports add a second boundary. A board controlled outer host embeds
the plugin as srcdoc under frame-src 'none', so the plugin cannot navigate its frame
to a network URL. The outer host creates a fresh MessageChannel after every document
load. The board verifies the opaque host window, contribution ids, and transferred port
before sending data. A reload therefore receives fresh data without exposing it to a
navigated document.
A ticket report receives a versioned postMessage event after each load and data update:
window.addEventListener("message", (event) => { if (event.data?.type !== "rev0:plugin-data") return render(event.data.payload)})The payload contains only capabilities declared in the manifest. There is no inbound bridge and no board mutation capability: the channel is one way, and a surface that declares nothing receives an empty payload.
Board-mediated relay
Section titled “Board-mediated relay”network only ever works against a target that already speaks CORS, because it is the
SANDBOXED FRAME itself that opens the connection. Some targets have no CORS story at
all: Docker’s Engine API cannot be exposed over TCP through its settings UI or
daemon.json on Mac or Windows, so there is no origin a frame could ever be pointed at.
relay is the other half: a surface names a target the BOARD itself connects to, and
the result is pushed in over a bridge. The frame never dials out.
[[ui]]id = "containers"slot = "ticket_report"asset = "ui/containers.html"relay = "unix:/var/run/docker.sock"relay_path = "/containers/json"relay is either unix: followed by an absolute socket path, or an exact http(s)
origin like network’s, with no wildcard, path or trailing slash. relay_path is the one
path the board fetches with a plain GET, every poll; both keys are required together.
Declaring relay anywhere other than a ticket_report, a region, or a chrome = "window" surface fails the bundle: those are the only surfaces with a bridge to push a
result over.
A ticket_report or region surface is pushed the result over the same bridge its
capability payload already crosses on. A chrome = "window" surface (opened
from App Space, its own asset served as a plain frame with no board data by
default) gets that same bridge host the moment it declares relay, instead of loading
its asset directly: network still works there too, side by side, for whatever else it
fetches directly. This is how docker-monitor itself reaches the Engine API: it has no
CORS story on Mac or Windows, so network alone could never work for it, however it is
mounted.
The board fetches relay_path from relay on its own timer (GET /plugins/{plugin_id}/relay/{surface_id}, polled every 5 seconds) and hands back
{"ok": true, "data": ...} or {"ok": false, "error": "..."}. That result rides
alongside the payload, not inside it: payload stays “only declared capabilities” (a
chrome = "window" surface may never declare one, so its payload is always empty), and
a fetch the board made on its own is a different thing from board data projected off a
capability:
window.addEventListener("message", (event) => { if (event.data?.type !== "rev0:plugin-data") return if (event.data.relay?.ok) render(event.data.relay.data)})This is deliberately narrow, and that narrowness is what keeps it safe next to real ticket data: the manifest names ONE fixed path, the board decides exactly what it fetches, and the frame never supplies a path, a method or a body of its own. There is still no inbound bridge: a plugin cannot ask the board to fetch something else, only receive what the manifest already declared.
Capabilities
Section titled “Capabilities”A capability is what a surface may KNOW, declared the same way as what it may draw. Each one is a fixed projection: a named function that copies the listed fields and nothing else, so a field added to a ticket or written into a timeline entry’s metadata tomorrow does not start crossing the bridge on its own.
| Capability | Scope | Payload key | Exactly what it includes |
|---|---|---|---|
ticket.summary | ticket | ticket | id, title, status, command |
ticket.fields | ticket | ticket_fields | status, type, priority, assignee, tags, created_at, updated_at |
ticket.activity | ticket | activity | One row per timeline entry: id, kind, author, content, created_at. Never meta |
ticket.review_findings | ticket | review_findings | Validated findings: id, path, side, line, hunk_header, label, decoration, body, resolved, severity |
ticket.list | board | ticket_list | One row per ticket on the same board, archived ones excluded: id, title, status, tags |
The scope column says what a capability needs in order to be projected at all, which
is what decides where it may be declared. A ticket scoped capability reads the open
ticket, so it only works somewhere a ticket is open: a ticket_report, or a ticket scoped
host region. A board scoped one reads only the board the surface is
mounted on, so it works anywhere.
What no capability includes: any plugin setting value, and in particular any setting
declared secret = true; any other plugin’s settings; any board secret or auth token;
and the ticket fields that are neither filing nor content, such as description,
plan, workdir, branch, session_id and runner_state. Timeline meta is excluded
wholesale, since it is an open blob an agent writes into, so ticket.review_findings
stays the only way to read anything out of it, in its validated shape.
Every payload is bounded, because a real board is large. Any projected list is capped at
200 rows, a prose field (content, a finding body) at 2000 characters, a
single line field (title, assignee, one tag) at 200 characters, and a ticket’s
tags at 20. Truncation is deterministic rather than an error: ticket.list keeps the
200 newest tickets, ticket.activity the 200 most recent entries, and over-long text is
clipped, so a surface renders part of a big board instead of failing on it.
An undeclared capability name fails the whole bundle at load time, like every other
invalid declaration. Capabilities are declared on a ticket_report or on a surface in a
host region: those are the surfaces the board hands data to, and they
share one board owned host, one sandbox and one bridge.
Native action slots
Section titled “Native action slots”A [[actions]] entry asks the board to render one of its own controls on the
plugin’s behalf. The plugin supplies text and a target. It never supplies React,
markup, classes, colours, callbacks, or DOM selectors, so the host keeps full control
of placement, styling, accessibility, and the data contract, and an action is not a
route into the host tree.
| Key | Meaning |
|---|---|
id | Identifier, unique per slot across all enabled plugins |
slot | ticket_toolbar_action is the only slot in API version 1 |
label | Button text, at most 32 characters |
description | Optional tooltip, at most 200 characters |
command | A command declared by this plugin |
view | A chrome = "window" surface declared by this plugin |
Exactly one of command and view must be set. Both targets must belong to the same
plugin: an action cannot name a built in command, another plugin’s contribution, or a
ticket_report. Unknown slots, missing or double targets, duplicate identifiers, and
oversized text fail the whole bundle before registration, exactly like every other
contribution.
Selecting a command action posts a normal ticket reply whose metadata names the
plugin command, which is the same path the composer’s skill picker uses. The runner,
worktree, permission gate, status transition, and failure toast therefore stay
authoritative, and the run is a single ordinary turn. The host pins the reply to the
default instruct intent, so a question posture latched by an earlier message cannot
quietly turn the action into a read only turn. A command action is hidden on epics,
which never run one. Selecting a view action opens that plugin window in the existing
sandboxed launcher, with no new iframe permissions and no new host data.
Contextual results do not travel back through the action. A command reports through
its declared ticket_report surface and that surface’s capability filtered bridge,
which stays the only way ticket data reaches plugin code.
Actions are published only for currently loaded plugins, and the frontend derives every button, and any open plugin view, from the live catalog. Removing a path, disabling a plugin, deleting its folder, or breaking its manifest therefore removes its buttons and closes its open view on the next refresh, with no board source change and no restart.
Trusted hooks
Section titled “Trusted hooks”Everything above is declarative: the board reads it and renders it. A [[hooks]] table
is different in kind. It names Python that the board imports and calls in its own
runner process, so it is gated by an explicit, revocable trust decision rather than by
loading the plugin.
[[hooks]]id = "lint-commit"event = "PreToolUse" # see the event table belowmatcher = "Bash" # required on a tool event; a tool-name regexentrypoint = "hooks:make_linter" # module:factory, inside the bundleon_error = "skip" # skip (default) or deny; tool events only# <bundle>/hooks.pydef make_linter(ctx): """Called once per run. `ctx` is a narrowed HookRunContext, never the board's gate.""" async def lint(input_data, tool_use_id, context): return {} # no objection return lint| Key | Meaning |
|---|---|
id | Identifier, unique per event across all enabled plugins |
event | One of the six below |
matcher | Which tools this hook runs for, as a regex. Required on a tool event, refused on any other |
entrypoint | module:factory, resolving to a .py file inside the bundle |
on_error | What a failure costs: skip contributes nothing, deny fails closed. Tool events only |
| Event | Fired by | Fires when | May return |
|---|---|---|---|
PreToolUse | the SDK, per matching tool call | before the tool runs | a deny, nothing else |
PostToolUse | the SDK, per matching tool call | after the tool ran | a block, a redaction, fenced context |
RunStart | the board | before the agent’s first turn of a run | a bounded note |
RunEnd | the board | once the run has finished, on every exit path | a bounded note |
PromptCompose | the board | while this turn’s prompt is assembled | a context string, which the board fences |
The bottom three are not SDK hooks: the runner calls them itself, so they take no
matcher (there is no tool to match) and cannot be declared on_error = "deny" (there is
no call to refuse, and a plugin may never end a run; both mismatches fail the manifest
rather than being quietly ignored). RunEnd carries the status the run ended in, read
back from the board; that is the only status change a plugin is told about, because a
change outside a run would have to fire in the board server process, which never
imports a bundle’s Python.
A RunStart / RunEnd hook decides nothing. Every decision-shaped field it returns is
dropped without being read, and the one thing it may hand back is a short note that
lands in the run log. A hook that wants to act on a repeat failure is trusted Python
and can act; what it cannot do is make the board act for it.
A PromptCompose hook returns {"context": "..."}, and the board puts that text
inside an untrusted-data fence with an attribution line of its own outside it. The
plugin never writes the fence, so a contribution that spells out a closing delimiter is
escaped and still lands as data. That is why this event exists only now: amending a
prompt that had no injection fencing would have handed plugins the exact hole the fence
was built to close.
matcher is required on purpose. To the SDK an absent matcher means every tool,
including the board’s own mcp__kanban__* control channel, so a hook with no matcher and
on_error = "deny" would deny the very tools a wedged run needs in order to report that
it is wedged. A hook that genuinely watches everything writes matcher = ".*" and says
so, visibly, in dump-config and in every refusal it emits.
A plugin hook may object to a tool call. It may never grant one. The context it
receives carries no permission-gate callables, and its return value is filtered: a deny
(or, on PostToolUse, a block) is honoured and attributed, while permissionDecision: "allow" and updatedInput are dropped outright: the first would pre-approve a call the
board’s own gate was about to hold, the second is a board-owned rewrite (the rtk command
rewriter’s).
What a plugin may do to text is exactly two things, and they apply at every contribution
point (a PostToolUse rewrite, an additionalContext, a tool result, a prompt
contribution):
- narrow text the board owns: a redaction, below, and nothing else;
- contribute text of its own, which is bounded and always arrives fenced.
A PostToolUse hook may also return updatedToolOutput, but only as a redaction. The
board accepts it only if it equals the original tool output with zero or more spans
replaced by the literal marker [REDACTED]: never longer, and never carrying any text
that was not already there:
async def scan(input_data, tool_use_id, context): text = (input_data.get("tool_response") or {}).get("stdout", "") if "AKIA" not in text: return {} redacted = re.sub(r"\bAKIA[0-9A-Z]{16}\b", "[REDACTED]", text) return { "hookSpecificOutput": { "hookEventName": "PostToolUse", "updatedToolOutput": {**input_data["tool_response"], "stdout": redacted}, } }A candidate that is longer than the original, that introduces any text outside those
[REDACTED] spans, or that changes a key other than the redacted one (a dict tool
response’s exit code, interrupted flag, …) is dropped and the model sees the
untouched original instead. A hook cannot pick its own marker string; only the literal
above is ever honoured.
additionalContext is the other half of that rule: a hook may add its own words to what
the model reads, and they arrive inside a fence attributed to the plugin, never as text
with the board’s standing.
A refusal always names its source, so a wedged run is diagnosable from the transcript:
Blocked by plugin hook 'commit-linter/lint-commit': commit message 'wip' is not aConventional Commit. Use 'type(scope): summary' with one of: build, chore, ci, ...Plugin hooks compose after every board contributor, so nothing a bundle declares can
run ahead of write containment or the justification gate. Ordering is assigned by the
board; order is not a manifest key, and a bundle that declares one fails to load.
A bundle declaring [[hooks]] does not load at all until a human has trusted its source,
and moving its pin or editing its code asks again. See
Plugin trust.
Agent tools
Section titled “Agent tools”A [[tools]] table publishes an MCP tool the agent can call mid-run. It is the same
kind of thing as a hook (Python imported from a trusted bundle, built by a factory
handed the same narrowed context), and it is gated by the same trust record. What makes
it different is who drives it: a hook fires when the agent happens to touch a matching
tool, a tool fires because the agent decided it wanted the answer.
[[tools]]id = "board_digest" # lowercase, digits, underscoresdescription = "A one screen digest of this board"entrypoint = "tools:make_digest" # module:factory, inside the bundle
[tools.input] # optional; string | int | number | boollimit = "int"# <bundle>/tools.pydef make_digest(ctx): """Called on the first call, with the same narrowed context a hook factory gets.""" def run(args): return f"{args['limit']} open tickets, 3 blocked" # a string, or anything str()able return runThree things are the board’s to decide, not the manifest’s:
- The published name. Every plugin tool lives on one board-owned server, as
mcp__plugin__<plugin_id>_<tool_id>. A bundle cannot present itself as one of the board’s own tools. - Approval. A plugin tool is not pre-approved. Its first call routes through the permission gate to the human, exactly like any other third-party MCP tool: trusting a bundle’s code and letting this run call it are two decisions.
- The result. The tool’s return value reaches the model as fenced data, bounded, so a tool cannot write instructions into a run.
A tool that raises hands the agent an error result and the run carries on. There is no
on_error here: an agent whose optional tool is broken should be told and left to decide
what to do about it.
Non-Claude engines spawn MCP servers themselves from a command line, so an in-process server has no equivalent there: a codex run sees a bundle’s hooks but not its tools.
Settings and secrets
Section titled “Settings and secrets”A [[settings]] entry asks the board for a knob or a credential. It appears under
this plugin’s own card in Settings > Connections > Integrations, rendered by the same generic
form every built in integration uses: a plugin costs no settings code and no
frontend code, exactly like the internal integrations in
rev0/integrations/registry.py.
[[settings]]key = "team-review_api_key"label = "API key"type = "string"secret = truedescription = "Key for the team's review service"
[[settings]]key = "team-review_severity"label = "Minimum severity"type = "select"options = ["low", "medium", "high"]default = "medium"| Key | Meaning |
|---|---|
key | The board setting key. Must start with the plugin’s own id, so two plugins can never collide |
label | Control label in the Settings dialog |
type | string, bool, int, or select |
default | Seed value; must match type (one of options for select) |
options | Choices for a select; required for select, invalid otherwise |
secret | Redacts the value out of GET /config behind a placeholder, like a built in integration’s credential |
description | Optional help text under the control |
Unlike a command or a UI surface, a plugin’s settings are only as live as the rest of
the plugin catalogue: they are computed fresh from the currently loaded plugins on
every /config and /config/schema request, so unloading a source drops its settings
from the dialog without a restart, and re-enabling it brings back whatever was last
saved.
A command’s instructions may reference its OWN plugin’s settings by key, wrapped in
double braces ({{team-review_api_key}}); the board substitutes the real, unredacted
value before the runner ever fetches the command profile. This is the ONLY place a
value is readable in full: GET /config always redacts a secret field, and a
plugin’s [[ui]] surfaces never receive settings at all, so a value never reaches a
sandboxed iframe.
Acceptance fixtures
Section titled “Acceptance fixtures”The automated acceptance test creates three independent folders outside the product tree:
- A Whale pet mounts always with no chrome, and proves dynamic visual mounting.
- A Snake game mounts on demand in a window, and proves interactive sandboxed UI in App Space.
- A team code review plugin combines a command profile,
SKILL.md, defaults, aticket_reportthat receives graded findings, and a native Review action in the ticket toolbar that launches the command. Snake adds aviewaction beside it, so both target kinds are exercised.
The first two are intentionally tiny smoke checks. Code review is the useful pilot.
Seeing everything that is loaded
Section titled “Seeing everything that is loaded”A plugin is only one of the board’s extension points, and until recently none of them
could be listed together. rev0 dump-config now prints them all in one pass:
$ rev0 dump-config # add --json for the raw recordsengine (3) claude agent engine (AgentProvider)...prompt_append (6) model_prompting ponytail ...runner_hook (6) hold_until_justified PreToolUse contain_writes PreToolUse ...plugin_action (1) draft-notes [release-notes] slot=ticket_toolbar_actionA row with no bracket is the board’s own; [id] names the plugin that published it.
The command is read-only and needs no running server.
Two of those groups are not lookup tables but ORDERED CHAINS: the agent’s system-prompt
appends and its SDK hook matchers. They used to be literals inside process_ticket and
are now registered contributions (rev0/contributions.py,
rev0/runner/composition.py), which is what makes them printable in the order
they actually run, and what gives a future trusted-plugin tier something to register
into. Adding one is a register call in composition.py with an order, not a runner
edit. Order is a behaviour contract, pinned by tests/test_contributions.py.
Registering a BOARD contribution is still Python, and deliberately so. A plugin reaches
the same chain only through [[hooks]], and only from a source a human has trusted; see
Plugin trust.