Skip to content
rev0Docs

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.

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 = 1
requires = []
[[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.

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.

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.

PropertyValuesMeaning
mountalways (default), on_demandOn screen with the board, or opened by a human and closable again
chromenone (default), windowA bare transparent frame, or a board drawn title bar with a close button
placementanchor (default), freeParked where the board puts it, or dragged wherever the human wants it
positionbottom_right (default), bottom_left, top_right, top_leftWhich corner an anchored surface sits in, and which corner a free one opens at
sizesmall, medium (default), large
min_size{ width, height }, default { width = 240, height = 200 }The floor a resize clamps to
resizableboolean, default true for placement = "free"Whether the window offers a corner grip
pointerpass_through (default), captureWhether taps reach the board underneath
launchernone (default), app_tileWhether App Space shows a tile that opens it
renderiframe (default), markupA 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.

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.

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"]
RegionScopeWhere it rendersWhat it can see
board_headerboardA compact strip above the board, in every viewThe board being viewed, so board scoped capabilities only
ticket_panelticketA section inside the ticket modalThat 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 ticket
scoped 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.

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.

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.

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.

CapabilityScopePayload keyExactly what it includes
ticket.summaryticketticketid, title, status, command
ticket.fieldsticketticket_fieldsstatus, type, priority, assignee, tags, created_at, updated_at
ticket.activityticketactivityOne row per timeline entry: id, kind, author, content, created_at. Never meta
ticket.review_findingsticketreview_findingsValidated findings: id, path, side, line, hunk_header, label, decoration, body, resolved, severity
ticket.listboardticket_listOne 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.

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.

KeyMeaning
idIdentifier, unique per slot across all enabled plugins
slotticket_toolbar_action is the only slot in API version 1
labelButton text, at most 32 characters
descriptionOptional tooltip, at most 200 characters
commandA command declared by this plugin
viewA 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.

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 below
matcher = "Bash" # required on a tool event; a tool-name regex
entrypoint = "hooks:make_linter" # module:factory, inside the bundle
on_error = "skip" # skip (default) or deny; tool events only
# <bundle>/hooks.py
def 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
KeyMeaning
idIdentifier, unique per event across all enabled plugins
eventOne of the six below
matcherWhich tools this hook runs for, as a regex. Required on a tool event, refused on any other
entrypointmodule:factory, resolving to a .py file inside the bundle
on_errorWhat a failure costs: skip contributes nothing, deny fails closed. Tool events only
EventFired byFires whenMay return
PreToolUsethe SDK, per matching tool callbefore the tool runsa deny, nothing else
PostToolUsethe SDK, per matching tool callafter the tool rana block, a redaction, fenced context
RunStartthe boardbefore the agent’s first turn of a runa bounded note
RunEndthe boardonce the run has finished, on every exit patha bounded note
PromptComposethe boardwhile this turn’s prompt is assembleda 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 a
Conventional 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.

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, underscores
description = "A one screen digest of this board"
entrypoint = "tools:make_digest" # module:factory, inside the bundle
[tools.input] # optional; string | int | number | bool
limit = "int"
# <bundle>/tools.py
def 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 run

Three 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.

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 = true
description = "Key for the team's review service"
[[settings]]
key = "team-review_severity"
label = "Minimum severity"
type = "select"
options = ["low", "medium", "high"]
default = "medium"
KeyMeaning
keyThe board setting key. Must start with the plugin’s own id, so two plugins can never collide
labelControl label in the Settings dialog
typestring, bool, int, or select
defaultSeed value; must match type (one of options for select)
optionsChoices for a select; required for select, invalid otherwise
secretRedacts the value out of GET /config behind a placeholder, like a built in integration’s credential
descriptionOptional 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.

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, a ticket_report that receives graded findings, and a native Review action in the ticket toolbar that launches the command. Snake adds a view action beside it, so both target kinds are exercised.

The first two are intentionally tiny smoke checks. Code review is the useful pilot.

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 records
engine (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_action

A 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.