The plugin API
A plugin is an ES module that exports activate(api). It runs in the
plugin host, a helper process under the OS sandbox, in a QuickJS runtime of
its own: one runtime per plugin, so no two plugins share globals. It never
draws; it pushes element trees and the kernel renders them with GPUI.
import { ui } from "convergence";
export function activate(api) { /* register things */ }
A plugin may split its code over sibling modules and import them by
relative path (import { fold } from "./grouping.js"), and import its
.json files as default exports. The runtime has the plugin's own
.js, .mjs, .ts, .mts and .json files, and the built-in modules
convergence, convergence/effect, convergence/services, zod and
effect/<Module> (effect/Effect, effect/Schema, ...; not the effect
barrel). Any other bare specifier (import x from "lodash") or a path out
of the plugin folder fails to load. Everything reloads when a file is
saved.
A .ts or .mts module runs with its types erased, nothing compiled:
the types become spaces, so an error's line and column are the source's.
Only TypeScript whose types erase can run (erasableSyntaxOnly in
tsc): an enum, a namespace with code, a parameter property,
import x = require() or a decorator fails to load with its file and
line. Types from another module come with import type; a .d.ts file
has no code and is never loaded. plugins/tsconfig.json type-checks the
plugins (pnpm -C plugins exec tsc -p tsconfig.json).
What the sandbox means
The helper process can open no file, socket or program, whatever the code in it does (macOS: Seatbelt; Linux: Landlock, seccomp and namespaces; Windows has no sandbox yet, and Settings says "Sandbox not available"). Everything a plugin does outside its own views goes through the host, which checks the plugin's grants first:
- files, the network, programs and the environment through
api.fs,api.net,api.processandapi.env(below); - the app's data through
api.host.*(host-api.md); - the window through
api.host.kernel(...),api.notify,api.openUrland the pickers.
There is no fetch, require, process or fs global; the API objects
are always there, and a call without the grant rejects at once with
PermissionNotGranted (see Permissions).
TypeScript and Effect
A plugin may be written in TypeScript and run its work as an Effect
program. The entry stays activate, but instead of writing it by hand
you build it with runPlugin and export what it returns:
// main.ts
import * as Effect from "effect/Effect";
import { Plugin, Views, runPlugin } from "convergence/effect";
import { html } from "convergence";
const program = Effect.gen(function* () {
const plugin = yield* Plugin;
const views = yield* Views;
yield* views.slot(
"right",
(ctx) => {
ctx.state.count ??= 0;
return html`
<div display="flex" flexDirection="column" padding="medium" gap="small">
<text style="heading">Hello</text>
<text style="body">Clicked ${ctx.state.count} times</text>
<button id="hi" label="Click" onClick=${() => {
ctx.state.count++;
ctx.update();
}} />
</div>`;
},
{ title: plugin.name },
);
});
export const activate = runPlugin(program);
convergence/effect provides one service per thing the api offers:
Plugin, Host, Kernel, Events, App, Views, Commands,
Slash, Palette, Tools, Agents, Hooks, Fs, Net, Process,
Env, Storage, Settings, Notify and Services. Every
registration is scoped: when the plugin unloads or reloads the scope
closes, and each view, listener, process and fork is released, so there
is no api.onUnload bookkeeping. A handler in a tree runs an effect
with ctx.run(effect). The official plugins are written this way;
plans/typed-plugins/STYLE.md is the style guide, and
plugins/examples/counter-service with counter-panel is a complete
provider and consumer pair.
Plugin code never calls Effect.runPromise or Effect.runFork itself:
runPlugin owns the runtime. Host failures are the tagged errors
PermissionNotGranted, NeedsReview and HostCallFailed.
Tests run in Node, not in the sandbox. plugins/testing/register.mjs
is a resolver hook (pnpm -C plugins test, or node --import plugins/testing/register.mjs --test <files>) that resolves
convergence to the real element factories and convergence/effect,
convergence/services and convergence/effect/testing to the built-in
sources, with zod and effect/* from plugins/node_modules at the
versions the helper bundles. testLayer({ host?, fs?, process?, net?, storage?, settings? }) from convergence/effect/testing replaces the
host side with in-memory fakes that record every call.
Imports follow the same rules as everywhere else: own modules with
their extension (./views.ts), another plugin's contract only with
import type, and the effect barrel is refused (effect/Effect,
never effect).
api
| Member | Description |
|---|---|
api.plugin |
this plugin's name (its folder) |
api.id |
its manifest id (local:<folder> without one) |
api.ui |
the element builders (same as the ui import) |
api.host |
the host API (see host-api.md); every call returns a Promise and needs the permission listed there (see Permissions below) |
api.host.kernel(method, params) |
kernel calls: pick_folder, pick_images, attach_image, image_data, forget_image, plugins, reload_plugin, set_plugin_enabled, quit, commands, run_command, keybindings.change, keybindings.open, keys.record, keys.record_cancel, context.set, palette.providers, palette.search, palette.run, settings_view, theme, themes, and the terminals below (see Commands and keys, Settings) |
api.fs, api.net, api.process, api.env |
files, network, programs and environment (see below) |
api.app.onChange(fn) |
receive the new app state on local changes and kernel pushes; returns an unsubscribe |
api.host.call(method, params, { signal }) |
call a host wire method; abort cancels the outstanding request |
api.host.kernel(method, params, { signal }) |
call a kernel method; abort cancels the outstanding request |
api.app.state() |
{ workspaceId, chatId, route, shared } |
api.app.update(patch) |
patch the app state (shared merges); this plugin reads its own change back at once, every other plugin a moment later |
api.app.select(chatId, workspaceId) |
focus a chat and route to chat |
api.app.route(name) |
change the center route |
api.app.share(key, value) |
put a value in shared for other plugins |
api.notify(message, level?) |
in-app notification (info, warning, error) |
api.focus(id) |
give the keyboard to an input of this plugin's tree, by node id; one not drawn yet takes it when it is |
api.openUrl(url) |
open an http, https or mailto link in the system browser, in answer to the user (see Permissions); resolves { opened: true }, or { opened: false, reason } for a link the kernel kept closed |
api.pickFolder() |
native folder picker; resolves { path } |
api.sleep(ms) |
resolves after ms milliseconds (a timer) |
api.pickImages() |
native file picker; resolves { paths } |
api.attachImage(path) |
hold an image file; resolves { id, name, mimeType, bytes, path } |
api.attachImageData(base64, name) |
the same for bytes (an image already in a transcript) |
api.imageData(id) |
the held bytes as { mimeType, name, data } (base64), for a prompt block |
api.forgetImage(id) |
let a held image go |
api.slashCommands() |
every plugin's /command: [{ name, plugin, description, inputHint }] |
api.runSlash(name, input, context) |
run one in its plugin; resolves `{ action: "prompt" |
api.toolRenderers() |
every plugin's tool renderers: [{ plugin, name, kind }] |
api.renderTool(call) |
ui.toolView({ call, id: call.id }) when a plugin renders that call, else null |
api.slot(slot, render, options?) |
register a view; returns { id, update(), remove() }. Options: on, order, title, flyout, key. A shell slot needs ui.slots (see Permissions) |
api.on(event, fn) |
listen to host events ("*" for all); returns a function that stops listening |
api.onUnload(fn) |
run fn({ reloading }) when the plugin unloads or reloads (see Cleanup); returns a function that removes it |
api.command({ id, run(api) }) |
runs a command the manifest declares (contributes.commands, with its title, key and when); an id the manifest does not declare throws (see Commands and keys); returns { remove() } |
api.settings |
the plugin's own settings: get(key?), set(key, value), onChange(fn), secret(key) (see Settings) |
api.context.set(key, value) |
a context key when expressions read, named <plugin name>.<key> |
api.palette.provider({ id, prefix, title?, search(query), run(item) }) |
a mode of the command palette (see Commands and keys) |
api.slash({ name, description?, inputHint?, run(input, context, api) }) |
composer /name; returns { remove() } |
api.toolRenderer({ kind?, name?, render(call) }) |
draw the tool calls of that name or kind (see Tool renderers) |
api.tools.register(tool) |
a tool every agent can call (tools.md); needs tools.provide. Its run(input, ctx) can show subagents under the call with ctx.subagent.start(...) (tools.md, Subagents) |
api.models.list(options?), api.models.prompt(options) |
the enabled agents with their models and efforts, and one prompt through one of them in a hidden session (see Models and subagents); need models.use |
api.subagents.defineType(type), .types(), .run({ type, input }, ctx) |
subagent types the main agent can start (see Models and subagents); defineType and run need models.use |
api.mentions.set(list), api.mentions.list() |
what this plugin offers after @ in the composer, and every plugin's offers (see Composer mentions) |
api.hooks.on(hook, fn) |
before_prompt, after_turn, tool_call, tool_approval (see Hooks) |
api.agents.register(agent) |
an agent (see Agents); needs agents.provide. Returns { emit(event), unregister() } |
api.agents.unregister(id) |
remove an agent this plugin registered; the host stops it and its sessions end. Returns whether it was registered |
api.services.provide(def, impl) |
register a service the manifest declares in provides; resolves { error(tag, fields), emit(event, payload), remove() } (see Services) |
api.services.get(name) |
a declared service's client: client.method(params, { signal }?) calls it (and client.method.result(...) resolves `{ ok, value |
api.services.available(name) |
whether a provider runs, as the host last said |
api.services.when(name, fn) |
run fn(client) on every change of the service's availability; what it returns cleans up the previous run. Returns the unsubscribe |
api.paths.data |
the absolute path of the plugin's own data folder (the data scope of its fs grants); null in a runtime that has none |
The web globals a plugin may expect are there: setTimeout,
clearTimeout, setInterval, clearInterval, queueMicrotask,
TextEncoder, TextDecoder (UTF-8), atob, btoa, AbortController,
AbortSignal, Headers, performance.now(), console, structuredClone
(plain values, arrays, Date, RegExp, Map, Set, typed arrays,
ArrayBuffer and errors, with cycles; a function or a symbol throws
DataCloneError) and crypto: crypto.randomUUID() (a version 4 UUID)
and crypto.getRandomValues(typedArray) (an integer typed array of at
most 65 536 bytes). Their random values come from a ChaCha20 generator the
host seeds with 32 bytes of the operating system's randomness at every
load, so they are as good as the system's for ids, nonces and secrets.
Terminals
The kernel runs the shells; a plugin owns the interface around them
(plugins/terminal/ is the built-in one). A build without terminal
support answers unknown kernel method, so a plugin that may run on an
older build should call terminal.list once before it registers its
views.
| Call | Answer |
|---|---|
terminal.create({ cwd, workspaceId, title?, command?, args? }) |
the new terminal: { id, title, cwd, workspaceId, running, exitCode } |
terminal.list() |
{ terminals: [...] } |
terminal.close({ id }) |
ends the shell and everything it started |
terminal.rename({ id, title }) |
a name of the user's own; an empty title gives the shell's own back |
terminal.write({ id, data }) |
writes to the shell's input |
terminal.focus({ id }) |
gives it the keyboard the next time it is drawn |
What happens to terminals arrives as the host event terminal
(events.md).
Views
const view = api.slot("left", (ctx) => tree, { on: ["chat_changed"], order: 0, title: "Chats" });
ctx.state is an object that survives re-renders and reloads. ctx.app is
the app state. ctx.update() re-renders this view. options.on lists the
host events that re-render the view; "app_state" re-renders on app state
changes; with no list, every non-agent host event and every app state
change re-renders. Agent events (agent) only re-render views that ask for
them, at most once per view every 30 ms. Views in the same slot stack top
to bottom in order.
The runtime decides when a view renders, and sends each new tree to the
kernel. A view id is unique in the app (each plugin has its own range),
so ui.view({ view }) can draw another plugin's view: the tabs plugin
draws the terminal's this way.
Return null to render nothing.
A view in the right slot is also what the rail shows when its plugin's
icon is hovered. By default that fly-out has the shape of the docked
column; options.flyout gives it one of its own:
api.slot("right", render, { flyout: { width: 864, height: 468, align: "bottom" } });
align is top (level with the top of the rail icon) or bottom (level
with its foot, for an icon low on the rail). The kernel keeps the window
inside the app window. A fly-out with a shape of its own can show beside
a pinned panel. Every fly-out closes a moment after the pointer leaves
it, even while the keyboard is inside it; the keyboard is let go with it.
A reload keeps a view when the new code registers it again: same slot and
the same options.key, or, without a key, the same place among the plugin's
keyless views in that slot. The view keeps its id, its ctx.state, and what
the kernel holds for it (text in inputs, the keyboard focus, open lists,
scroll). A view registered later from a list (one per pane, per tab) must
pass key, or it takes the id of another. Until the reloaded plugin's host
calls settle (at most one second), its runtime sends no trees and the
kernel keeps the old ones on screen,
so a plugin that loads its data again does not show an empty frame. Its
views still render in that time, so data a render asks for loads too. An
input whose live text differs from the value the first new tree passes
keeps the live text and the caret, and reports the text through
onChange.
A view may be registered at any time, not only in activate, and the handle's
remove() unregisters it. A view in a slot the shell does not place (the
chat plugin uses pane) is drawn only where another view embeds it with
ui.view({ view: handle.id }). Each view is its own render unit: its own
dirty flag, its own retained transcript, markdown and split state. That is
how the chat grid re-renders one pane while a chat streams into it.
Templates and div
html builds elements from a tagged template, without a build step
(htm, vendored in the runtime):
import { ui, html } from "convergence"; // also api.ui.html and api.ui.h
api.slot("right", (ctx) => html`
<div style=${{ padding: 8, gap: 6 }} display="flex" flexDirection="column"
background="card" borderRadius="md" onClick=${() => open()}>
<text tone="muted">${title}</text>
${rows.map((row) => html`<div key=${row.id} hover=${{ background: "hover" }}>${row.label}</div>`)}
</div>`);
h(type, props, ...children) is the factory behind it: a string type is a
node type (div, text, markdown, button, ...), a function type is a
component called with { ...props, children }. A style object spreads
into the props, so style=${{ padding: 8 }} and padding=${8} are the
same. A string child is a text node that takes the text style of the div
around it; <text> takes its text from its children. key (or a string
id) keeps a child's identity when the list around it changes.
Native components are tags too: <button id="save" label="Save" onClick=${save} />, <input>, <select>, <switch>, <icon name="x" small />, <markdown text=${md} />, <code language="json">${json}</code>,
<popover> (its first child is the anchor, the second what opens),
<scroller id=...> (its children are its items), <split>, <view>,
<tool_view>, <security_card>. ui.list(...) and ui.tree(...) tidy
their items, so call them inside the template: ${ui.list({ id, items })}.
A view written as a template spells out what the old shortcuts gave by
default. ui.column(p) is <div display="flex" flexDirection="column" gap="small" padding="small">, and ui.row(p) the same with
flexDirection="row" alignItems="center"; fill is flexGrow=${1} flexShrink=${1} flexBasis="0%" minWidth=${0} minHeight=${0}; surface is
background; radius is borderRadius; scroll is overflowY="scroll";
hover: true is hover=${{ background: "hover" }} with group="hover"; a
clickable container also has cursor="pointer". The built-in plugins keep
these in a few objects and spread them:
const column = { display: "flex", flexDirection: "column" };
const row = { display: "flex", flexDirection: "row", alignItems: "center" };
const clickable = { group: "hover", cursor: "pointer" };
html`<div ...${row} ...${clickable} gap=${6} padding="small" borderRadius="sm"
hover=${{ background: "hover" }} onClick=${open}>
<icon name="file" small tone="muted" />
<text style="body" size="xs" truncate>${name}</text>
</div>`;
div is the one general container. Props, all optional:
- Layout:
display(flex|grid|block|none),flexDirection(row|column|row-reverse|column-reverse),flexWrap,flexGrow,flexShrink,flexBasis,alignItems,alignSelf(start|center|end|stretch|baseline),justifyContent(start|center|end|between|around|evenly),gap,rowGap,columnGap; simple grid:gridTemplateColumns(a count of equal columns, or a list of tracks such as[120, "1fr", "auto"]: pixels, a share of the rest, the width of the cell's content, row by row) andgridColumnon a child (a span:2,"span 2"). - Size:
width,height,minWidth,minHeight,maxWidth,maxHeight: a number (px),"50%","auto"or"fill"(100%). - Spacing:
padding,paddingTop|Right|Bottom|Left,paddingX,paddingY, and the same formargin(which also takes"auto"): numbers, or the stepsnone|small|medium|large. The most specific side wins. - Position:
position(relative|absolute),top,right,bottom,left,zIndex(orders absolutely placed siblings; it never lifts anything out of the view). - Paint:
background(a color, or{ linearGradient: { angle, stops: [[color, at], ...] } }, drawn from its first to its last stop),color,opacity,borderandborderTop|Right|Bottom|Left({ width, color }),borderRadius(sm|md|lg|xl|xxl|fullor px) or per corner (borderTopLeftRadius, ...),shadow(a list of{ x, y, blur, spread, color, inset }, at most 4),overflow,overflowX,overflowY(visible|hidden|scroll),cursor(default|pointer|text|grab|grabbing| move|not-allowed|crosshair|ew-resize|ns-resize),visibility. - Text inside:
fontFamily(sans,mono, or a bundled family),fontSize(px orxs|sm|base|lg|xl|2xl),fontWeight(a number ornormal|medium|semibold|bold),lineHeight,textAlign,lineClamp,whiteSpace(normal|nowrap),truncate. - States:
hover,active,focusanddisabledhold paint and text props (background,color,opacity,border,borderColor,shadow,fontWeight,visibility,underline) that apply in that state;disabledapplies whileisDisabledis true, which also stops the div's events.group: "name"names a hover group, andgroupHover: { group, ...state }applies while the pointer is on that group's div. - Behavior:
id(a stable id of your own),focusable,stopPropagation(a pointer event handled here does not reach the divs around it),followEnd(withoverflowY: "scroll"),reveal(withoverflowX: "scroll": the index of the child to keep in view),drag,drop,onPaste,celebrate(see the old container props below).
Colors are theme tokens or hex (#rgb, #rrggbb, #rrggbbaa). The tokens
are the surfaces and tones of DESIGN.md: background, card, muted,
subtle, sidebar, popover, accent (the selected surface), stage,
bubble, hover, foreground, mutedForeground, faint, primary,
primaryForeground, info, success, warning, warningForeground,
danger, border, transparent. A token takes a fade: "danger/35" is the
danger tone at 35%. With tokens a plugin follows the theme and its light and
dark modes by default.
Events
Handlers stay in your plugin. The tree the kernel gets says only that a
handler exists; when the event happens the kernel sends it back as data
and the runtime calls your function. A handler may be a new function on
every render: that costs nothing. On a div:
| Event | Payload |
|---|---|
onClick, onDoubleClick |
{ x, y, button, count, modifiers } |
onSecondaryClick (a right-click; it does not reach the divs around) |
{ x, y, button, modifiers } |
onPress (the left button going down: a menu's anchor) |
{ x, y, button, modifiers } |
onPointerDown, onPointerUp |
{ x, y, button, count?, modifiers } |
onPointerMove (one per frame, the latest) |
{ x, y, button, modifiers } |
onPointerEnter, onPointerLeave |
{} |
onWheel |
{ x, y, dx, dy, modifiers } (pixels) |
onKeyDown, onKeyUp (the div needs focusable; a click focuses it) |
{ key, code, char, modifiers, repeat } |
onFocus, onBlur |
{} |
x and y are inside the div, from its top left corner. button is 0
(left), 1 (middle) or 2 (right); modifiers is { shift, ctrl, alt, meta }.
Drag and drop is the explicit drag and drop pair, never the
platform's (files dragged in from outside excepted).
canvas
{ type: "canvas", width, height, commands } (or ui.canvas(props)) paints
a draw list inside its box, clipped to it. It takes a div's props, events
included, so pointer events come back with coordinates in the canvas.
Commands:
- shapes, which become the current shape:
{ op: "rect", x, y, width, height },{ op: "roundRect", ..., radius },{ op: "path", d: [["moveTo", x, y], ["lineTo", x, y], ["quadTo", cx, cy, x, y], ["cubicTo", c1x, c1y, c2x, c2y, x, y], ["close"]] }; each also takesfill,strokeandlineWidthto paint at once; { op: "fill", color }and{ op: "stroke", color, width }paint the current shape;{ op: "text", x, y, text, size, color, align: "left|center|right", baseline: "top|middle|bottom", weight, font }: one line,yits top by default;{ op: "image", id, x, y, width, height, radius }: an image the kernel holds (an attachment id);{ op: "clip", x, y, width, height },{ op: "save" },{ op: "restore" },{ op: "translate", x, y },{ op: "scale", x, y? }.
Colors in commands are the same tokens and hex values as a div's, faded
tokens included ("primary/55"). plugins/examples/chart draws a bar and
a line chart this way: its canvas reports onPointerMove and
onPointerLeave, the plugin works out the day under the pointer, and a
div placed over the canvas (position: "absolute" inside a relative
div) shows the tooltip.
Motion
motion: { to, duration, easing, delay, repeat, alternate, onDone } on a
div animates it in the kernel; no JavaScript runs per frame. to names
opacity, width, height, top, left, right, bottom (pixels),
x and y (an offset from where layout puts the div), scale (the div's
pixel width and height around its centre; its content keeps its size) and
background. duration and delay are milliseconds (200 and 0 by
default); easing is linear|easeIn|easeOut|easeInOut|spring (easeOut by
default); repeat is a number of further plays or true for ever, and
alternate runs every other play backwards. The motion starts from where
the div is; a new to starts from where the running one has got to.
onDone is called once, when the last play ends. The app's reduced motion
setting jumps to the end.
html`<div width=${0} height=${8} background="success" borderRadius="full"
motion=${{ to: { width: 240 * share }, duration: 700, easing: "spring" }} />`;
The bar grows from 0 to its share; when share changes it grows or
shrinks from where it is.
How a tree reaches the kernel
The runtime keeps the last tree of each view and sends only what changed
(ui/patch { view, ops }: create, setProps, setText, insert,
move, remove, root); the kernel keeps its own copy and draws from it.
Children are matched by key (a string id stands in for a missing one),
then by type and position, so give rows of a list that changes a key.
Events come back as ui/event { view, node, event, payload, seq }; if the
two copies ever disagree the kernel asks for the whole view again
(ui/resync). Each patch names the last event the runtime had handled
(seen). An input's value drawn before the plugin handled the user's
latest edit is a step behind, so the input keeps the user's text and caret;
a value drawn after it replaces the text. An onChange handler should
therefore store the value before it awaits anything.
Checking a view
A change that must not change what a view shows (a refactor, a move from
ui.column to templates) is checked without a window. Copy the plugin's
folder to <folder>/<name> before the change, then run
CONVERGENCE_UI_BASELINE=<folder> cargo test -p convergence-kernel --test plugin_equivalence: it runs the old
and the new copy in the runtime side by side, with fixed chats,
transcripts, settings, git status, terminals and app state, a fixed clock
and fixed randomness. It records every view's tree as the kernel holds it,
fires every handler the views show (and what those bring on screen), and
requires the trees, the app state and the host calls to be equal after
every step. A difference the change means to make is listed in the test's
normalize, with its reason. The built-in plugins have tests there; a
plugin of your own gets one by adding a run(...) with its fixtures.
Elements
Containers: ui.column(props, children), ui.row(props, children). They
are shortcuts: each makes a div (a flex column or a centred flex row)
with the same look the props below always had, so both old and new
plugins draw through the one container renderer.
Props: gap (none|small|medium|large, or a number of pixels when the
layout has to line up with a control's own inset), padding, paddingX,
paddingY (the same values),
fill (grow), full (fill both axes), scroll, scrollX (scroll
sideways, for a strip of tabs: children keep their width and a vertical wheel
moves the strip), reveal (with scrollX and an id, the index of the child
to bring into view whenever it changes: the active tab), followEnd (with
scroll and a stable id, keep following appended children while already at
the end; a reader who scrolls up stays put),
onSecondaryClick (a right-click, which is how a plugin opens a context
menu: hold the menu open in the plugin's own state and draw it with
ui.popover),
surface (background|card|muted|subtle|sidebar|popover|accent|bubble|stage|tint; tint fills with the container's tone),
rounded or radius (sm|md|lg|xl|xxl|full), bordered with tone,
wrap, align/justify (start|center|end|between), width, height,
maxWidth (px), maxHeight (px; with scroll the box scrolls past it),
center (centre a capped box), stack (children drawn over each other, centred),
id (a stable element id for a container that scrolls or is dragged),
stopClick (the click ends here instead of also reaching the row around),
floatEnd (floats over the end of the parent, taking no space: hover actions),
celebrate: { token, onDone } (a short burst around the container, once per
token, then onDone; needs id),
drag: { type, data, label } (the container can be picked up; label is the
chip under the pointer) and drop: { types, zones, files, onDrop(e) } (a target for
dragged values of those types; zones is single, bands for
before | into | after by thirds, or edges for left | right | top | bottom | center; e is { type, data, zone }. With files: true the target also
takes files dragged in from outside the app, which arrive as
{ type: "files", paths, zone }),
onPaste(e) (a paste inside the container that carries images or file paths
rather than text: e is { images: [{ id, name, mimeType, bytes }], paths },
the images already held by the kernel. A plain text paste is not intercepted
and reaches the input it landed in),
plainCursor (a click target that keeps the arrow cursor), hideOnHover,
opacity, textSize and lineHeight (px,
inherited by everything inside, markdown included), revealOnHover (hidden
until the pointer is on the nearest hover or onClick ancestor), and
onClick with hover for clickable rows. onPress is the same callback
on the press rather than the release; use it for a menu or popover
trigger, which should open under the finger, and keep onClick for an
action, which the user must be able to call off by sliding away.
radius also takes a number of pixels. Children may be strings,
elements, arrays, or null.
Text: ui.text(text, { style, truncate, tone, size, bold, weight, lineHeight, center })
with style body|muted|heading|title|mono|small|section (ui.text is
body unless told otherwise; a text node written as { type: "text" } or
in a template has no style of its own and takes the div's, and takes a
div's text props: color, fontSize, fontWeight, fontFamily,
textAlign, lineClamp, whiteSpace), tone
foreground|muted|faint|primary|info|success|warning|danger, size
xs|sm|base|lg|xl|xxl or a number of pixels, weight a font weight number
(400, 500, 560, 600) and lineHeight in px; ui.heading(t), ui.muted(t),
ui.markdown(t), ui.code(t, language), ui.diff(diffText, path),
ui.badge(text, variant) (default|info|success|warning|danger),
ui.icon(name) (Lucide names such as plus, folder, search, and the kernel's plus-large, a plus as wide as the icons beside it in a row, and folder-solid, a filled folder; color gives an icon one of the preset colours),
ui.image({ id, path, width, height, maxHeight, radius }) (a picture: id is
an attachment the kernel holds, path a file on disk the plugin may read
with its fs.read grant, else a placeholder is drawn, except a workspace's icon, which any plugin may draw; sizes are pixels).
Controls (all need a stable id):
ui.button({ id, label, icon, variant: "default|primary|ghost|danger|outline|chip|accent", small, disabled, loading, active, tooltip, badge, onClick })—badgeis a short count drawn on the button's corneraccentis filled with the active selection colour (list.active), for a panel's one action (Commit).ui.input({ id, value, placeholder, multiline, submitOnEnter, ghost, onChange(e.value), onSubmit(e) })— submission supplies{ value, secondary, shift };secondarymeans ⌘ on macOS and Ctrl elsewhere. Multiline submits on ⌘⏎/Ctrl+Enter, or on ⏎ withsubmitOnEnter(⇧⏎ inserts a newline). Existing handlers may ignore the extra fields. The kernel reports modifiers, not send/queue policy. Stock chat maps plain Enter to Send/Steer and secondary Enter to Queue; picker Enter accepts its selection first.ghostdrops the frame (only for an input inside a surface that already frames it: the composer, a picker's search);icondraws a Lucide icon inside the field before the text, and gives the field a list row's label type and corner (it heads rows, as the sidebar's search does)catchTyping: truegives this input whatever the user types while nothing else has the keyboard: the window focuses it and replays the keystroke, so a thought can be typed without clicking first. One input per window should claim it.ui.checkbox({ id, label, checked, onChange(e.value) }),ui.switch(...)ui.select({ id, value, options: [{ value, label, svg? }], placeholder, small, ghost, onChange(e.value) })— optionalsvgmarkup appears in both the trigger and menu rowsui.combobox({ id, look, multiple, value, values, label, detail, marks, placeholder, tooltip, disabled, options, search, all, actions, width, empty, onChange, onStar })(<combobox>in a template) — every dropdown (selectis drawn as one).look:field(default),compact,toolbar(a frameless label, the composer's pickers) orrow(anitem's metrics, the sidebar). An option is{ value, label, description?, detail?, image?|svg?|icon?, color?, group?, starred?, disabled?, reason?, submenu? }(color: one of the preset colours, for aniconmark: a workspace'sfolder-solid):groupputs consecutive rows under a heading that folds (shown with two or more groups),starredshows a star reportingonStar({ value, option? }),detailis muted text at the row's end, andsubmenu({ value, options, search?, width?, empty?, context? }) opens a list beside the row whose choice reportsonChange({ value, option })withoptionthe row's value; acontextsubmenu opens on a right click, a Control-click or Right only, never on hover, and the row's plain click stays its own choice (a workspace's look in the sidebar's list). One value:onChange({ value })and the list closes.multiple: a check at the end of each chosen row, the list stays open,onChange({ values })in the options' order,allnames a first row standing for every option (no values means all).actions([{ label, icon?, onClick }]) are rows under a line that close the list. The trigger showslabel(else the chosen labels, elseplaceholder), thendetailfainter, aftermarks(else the chosen option's). Keys: up, down, Enter, Right into a submenu, Left and Escape out.ui.tabs({ id, tabs: [{ id, label }], active, segmented, onChange(e.value) })—segmenteddraws a segmented control (a few choices of one setting) instead of a tab barui.text(label, { keys: true })— a key binding (⇧⌘P, strokes of a chord separated by a space:⌘K ⌘S) drawn as key caps Components (the kernel draws the look; give only content and behaviour, and do not restyle them):<item id label sublabel sublabelTone stacked icon iconSvg iconTone busy hoverIcon onIconClick selected strike strong badge actions onClick>…</item>— a list row: a leading mark, the label and an optional second line, the children at its end (centred on the label's line, never making the row taller),stackedputting the mark level with the label and the second line under the mark too (a chat row), andactions([{ icon, tooltip, onClick }]) over its right edge while the pointer is on it. A<slot name="leading">child replaces the icon. WithonIconClickthe mark is a button of its own, showinghoverIcononly while the pointer is on the mark (a chat row's check-off circle). The div props (id,drag,drop,celebrate,onClick, …) work as on a div.<card divided>…</card>— a raised surface holding rows;divideddraws a line between them.<panel>…</panel>— an inset muted surface (a log, a note inside a card).<setting title description stacked>control</setting>— a labelled setting with its control beside it (under it whenstacked).<callout tone title text icon>actions</callout>— a message with a tone (info,success,warning,danger).<progress value tone />— a bar; novaluefor work of unknown length.<tabs segmented …>,<text keys>⇧⌘P</text>and<input icon="search">— see the controls above.
A kernel draws a node type it does not know (one from a newer kernel) as nothing, so the rest of the view still draws.
ui.collapsible({ id, title, open, onToggle(e.open) }, children)ui.popover({ id, open, align: "start" | "center" | "end", width, onDismiss }, [anchor, content])— the anchor stays in the flow; whileopen, the content floats below it over everything else (taking no space) and a press outside it or Escape callsonDismiss. The plugin ownsopen, so the anchor toggles it andonDismissclears it. A press on the anchor itself is not "outside", so the toggle is a real toggle. Give the anchoronPressrather thanonClick: the list then opens on the press, like a menu.ui.overlay({ id, open, width, top, keys, hold, onRelease, onDismiss }, [content])— a panel over the whole window, centred,topof the way down it (a fraction of the window height, 0.14 by default). It takes no space where it is written, so it can sit anywhere in a view that is always drawn. Whileopenit holds the keyboard: it takes focus and gives it back when it closes,keysmaps a key name (up,down,left,right,enter,escape, or the character itself) to a callback, and a press outside it or the window going behind callsonDismiss.holdnames a modifier (ctrl,alt,shift,cmd) whose release callsonRelease, which is how a Ctrl+Tab cycle commits. The kernel draws the card (surface, border, shadow, radius); the content is yours. The plugin ownsopen.ui.view({ view })draws another registered view in place;ui.split({ id, direction: "horizontal" | "vertical", sizes: [a, b], onResize(e.sizes) }, [left, right])is a resizable pairui.list({ id, fill, items: [{ id, label, sublabel, icon, tone, strike, selected, badge, onClick, actions: [{ icon, tooltip, onClick }] }] })ui.icon(name, { small, tone, svg })—svgdraws inline SVG markup in place of the named icon (currentColorfollows the text colour,currentMutedthe muted surface)
Layout: ui.divider(), ui.spacer(), ui.spinner(), ui.empty(text).
A secret's field: ui.secretInput({ id, plugin, setting, placeholder, onSaved })
(<secret_input>) is the kernel's own masked input with a Save button. What
is typed goes straight to the system credential store for the setting
setting of the plugin in the folder plugin (not key, which a template
keeps for the element's own key); onSaved hears { ok, error? },
never the value. A plugin draws it for its own secrets; the settings page
(plugins.manage) for any plugin's.
Security cards: ui.securityCard({ kind: "approval", chatId, id }),
ui.securityCard({ kind: "request", id }),
ui.securityCard({ kind: "publish", chatId, id }) or
ui.securityCard({ kind: "report", id }) marks the place of a card that
only the app draws: an agent's approval (id is the approval's id), a
plugin that waits to be enabled or to get more permissions, the publish
row, or a report to the marketplace (id is a SecurityRequest id, see
host-api.md). The plugin chooses only where the card goes; its content
comes from the host, and other props are dropped. A publish row is drawn
only where it is placed as publish, and request draws the enable,
permission, report and bug report cards. A fix's attach row
(kind: "attach_fix" in the request) takes the publish row's place below
a fix chat's latest response and is placed as publish (or request). The kernel draws nothing there once the
approval is answered or the request is resolved, for an id it does not
know, inside a tool renderer, and for a request without a chatId (that
one is a modal the shell shows over the window). The card paints above
every plugin layer, popovers and overlays included, but keeps the clip of
the place it sits in; its buttons that act work once it has been in view,
unmoved and whole in width, for 600 ms (Cancel, Not now and Never act at
once: saying no is never the risk). The chat plugin puts approvals where
its transcript ends, requests after their afterItemId item, and the
publish row below the latest response once the turn is over
(security.pending gives them when a chat loads; security_request and
security_resolved keep them current).
The publish row shows the plugin, what the turn changed of it and its
permissions, with "Publish…" (a small form: visibility, the people to
share with, and a sign-in when nobody is signed in; basedOn is filled in
from where the plugin came from), "Not now" and "Never for this plugin"
(kept as the plugin's publish setting in its install record, never in its
folder). The report card shows the listing id, the version, the reason,
the files and lines, who reports and the registry it goes to: exactly the
body that is sent.
Links: convergence://market/<publisher>/<name>[@version] (from a web
page, a terminal, another app) routes the center to market and shares
marketOpen: { id, version, nonce } in the app state; the marketplace
plugin opens that listing when the nonce changes. A link only navigates:
it never installs, scans or starts an agent.
Stateful primitives (state retained per id):
ui.markdown(text, { id })keeps the rendered state and appends streamed text cheaply.ui.scroller({ id }, items)is a virtualized list that keeps following its end while items are appended.ui.tree({ id, items: [{ id, label, icon, openIcon, children, open, status, badge, hint, actions }], selected, onSelect(e.id, e.folder) })— the file tree (Files and Git panels): a chevron per folder, a file icon in its language's colour, a faint guide per level,status(modified,added,deleted,renamed,untracked,ignored,conflict) as the name's colour withbadge(its letter) at the end,hintas faint text after the name, andactionswhile the pointer is on a row. Givefiles: [{ path, label, status, badge, hint, actions }]instead ofitemsand the kernel makes the folders (openFoldersopens them all; a folder's id is its path with a/).fitmakes the tree as tall as its open rows, for a tree among other content.ui.editor({ id, text, language, readOnly })is a highlighted editor with line numbers.ui.diff({ path, diff })orui.diff({ path, oldText, newText, maxLines }).ui.terminal({ terminal })draws a shell the kernel runs. It paints no background of its own, so it takes the colour of the surface it is placed on.terminalis the idterminal.createreturned. The session is not part of the tree: it keeps running while nothing draws it, and several views may show it.
Nothing else is native: the transcript, composer, file tree and file viewer
are plugin code in plugins/chat/main.js and plugins/files/main.js.
Inputs are controlled: pass the value you last received in onChange back
as value; passing a different value (for example "" after sending)
replaces the text.
Files, network, programs, environment
The host does these for the plugin, inside its grants (../AGENTS.md,
Permissions). A call without the grant rejects at once with
PermissionNotGranted; the host checks every call again.
api.fs (fs.read, fs.write)
| Call | Answer |
|---|---|
read(path, { encoding?, workspaceId? }) |
the text (utf8, the default), a base64 string (base64) or a Uint8Array (binary) |
write(path, data, { encoding?, workspaceId? }) |
writes a string or bytes |
list(path) |
`[{ name, kind: file |
stat(path) |
{ kind, size, modified } (modified in milliseconds since 1970) |
mkdir(path, { recursive = true }), remove(path, { recursive }), rename(from, to) |
|
watch(path, fn) |
calls fn(paths) when something under path changes; resolves { close() } |
A relative path with workspaceId is inside that workspace. The grant's
scope decides the rest: workspace, plugin (the plugin's own folder),
data (a folder of its own the host keeps for it, at api.paths.data,
made before the plugin loads) or a path glob. A path counts by where it
lands: a symlink is followed and the file it leads to must be covered by a
grant, so a link inside a granted folder that points outside it is refused
unless another grant names its target (list the target's folder too, as
the Claude provider does with ~/.agents/skills/**). A .. or a link that
leaves the folder the grant names after the check is refused too.
list shows a link as symlink without following it.
api.net (net)
const response = await api.net.fetch("https://api.github.com/repos/x/y", { headers: { accept: "application/json" } });
if (response.ok) console.log((await response.json()).stargazers_count);
for await (const { event, data } of api.net.sse("https://example.com/events")) { /* ... */ }
const socket = await api.net.websocket("wss://example.com/live");
socket.send("hello");
for await (const message of socket) { /* a string, or a Uint8Array */ }
fetch(url, { method, headers, body, signal }) resolves a Response with
ok, status, statusText, url, headers, text(), json(),
bytes()/arrayBuffer() (a Uint8Array) and body, an async iterable of
chunks with text(), json() and lines(). body may be a string, bytes,
or an iterable of them (sent as it is read). A host is reached only when
the grant names it, and every address it resolves to must be public: a
name that resolves to 127.0.0.1 or a private network is refused unless
the grant is localhost:<port>. Each redirect (at most 5) is checked
again. Aborting signal before the response headers arrive gives up the
request itself (the host stops it, and fetch rejects with the signal's
reason, an AbortError); after them it stops the body.
api.process (process, process.any)
const child = await api.process.spawn("gh", ["pr", "list", "--json", "title"], { cwd: "/path/to/repo" });
const out = await child.stdout.text();
const { code } = await child.exited;
spawn(program, args, { cwd, env }) resolves { pid, stdin, stdout, stderr, exited, kill(signal) }: stdin.write(data) and stdin.close(),
stdout and stderr streams like a response body, exited resolves { code, signal }. process grants bare program names only, found on the
login PATH; a path, or an interpreter (sh, node, python, ...), needs
process.any. A child runs in its own process group with the login
environment plus env, and is killed when the plugin unloads. What a
child writes reaches the plugin whole, also after the child exited: the
host reads its output through a buffer of 4 MiB (macOS) or a pipe grown
to 1 MiB (Linux), so a program that writes without blocking and then
exits (Node, Bun) loses nothing.
which(program) resolves { path, realPath }: the file spawn would
run for program, and that file with its links followed (both null
when it is not there). It needs what spawn of that program needs.
Writes to one stream (a child's input, a request body, a socket) reach it in the order the plugin made them, whether or not it waited for each.
api.env (env)
get(name) resolves the variable from the login environment, or null.
Only the names the grant lists.
Streams are flow controlled: the host sends at most 1 MiB ahead of what the plugin has read, so a stream nobody reads waits and the others go on.
Commands and keys
A command is declared in the manifest and run by code:
"contributes": {
"commands": [
{ "id": "pr-tools.open", "title": "Open pull request", "category": "PR tools",
"key": "cmd+alt+o", "when": "workspaceOpen && !pr-tools.busy" }
]
}
api.command({ id: "pr-tools.open", run: (api) => open(api.app.state().workspaceId) });
idis<plugin name>.<command>. The manifest declares every commandapi.commandregisters (an undeclared one throws), so the palette and the Keyboard Shortcuts section know all commands before any plugin loads. The title, the category, the key and thewhenare the manifest's; what the code passes besidesidandrunis ignored.keyis a chord: modifiers (cmd,ctrl,alt,shift,fn) and a key joined by+or-, strokes separated by a space (cmd+k cmd+s). A key is a character or a name (enter,escape,tab,space,backspace,delete,up,down,left,right,home,end,pageup,pagedown,f1–f24).whenis an expression over context keys:!,&&,||, parentheses, and==/!=against a literal (pr-tools.mode == 'review'), at most 1024 bytes long and 32 levels of(and!deep. While it is false the key is not the command's (it reaches whatever else takes it, an input types it) and the palette hides the command. The kernel setscomposerFocused(the chat composer, the input withcatchTyping, has the keyboard),terminalFocused,transcriptFocused(the center shows a chat and no input, terminal or overlay has the keyboard),workspaceOpenandoverlayOpen. A plugin sets keys of its own withapi.context.set(key, value), named<plugin name>.<key>(kernel/context.set; another plugin's name is refused);nullremoves one, and a plugin's keys go when it unloads.- A command runs in its plugin, with that plugin's permissions only.
kernel/run_command { id }runs one: free for the plugin's own,commands.runfor another's (the palette). The kernel's own keys are commands too:kernel.openSettings(⌘,),kernel.toggleSidebar(⌘B),kernel.toggleDetail(⌘⇧B),kernel.reloadPlugins(⌘⇧R) andkernel.quit(⌘Q).
The user's keys are in <data dir>/keybindings.json, which the Keyboard
Shortcuts section of the settings page writes and anyone may edit (comments
and trailing commas are fine; the section rewrites the file without them):
[ { "command": "pr-tools.open", "key": "cmd+k cmd+o" },
{ "command": "-sidebar.newChat", "key": "cmd+n" } ] // "-" removes a binding; without a key, all of them
Every change to a manifest, to which plugins are enabled, or to the file rebinds the whole keymap. Who gets a key:
- The user's entry always wins.
- Without one, a default of the app or an official plugin wins over a third-party default; between two third-party plugins the one installed first keeps it. The other command is not bound, and Settings shows the conflict with an Assign button.
- A third-party default needs
cmd,ctrloralt(F1–F12 need nothing), andcmd+q,cmd+,,cmd+shift+pandcmd+pare the app's. The user can still bind any key by hand.
kernel/commands answers every command with its keys: { commands: [{ id, title, category, plugin, pluginTitle, official, kernel, key (the default as written), defaultKey, keys, labels (as a keyboard shows them: ⇧⌘P), source: default | user | none, conflict: { key, label, with, withTitle } | null, problem, when, enabled (its when holds now), registered (its plugin runs) }], errors, file }. The settings page changes keys with keybindings.change { command, change: set | unbind | reset, key? } and keybindings.open
(settings.write), and records one with keys.record (the next keystroke,
before any binding takes it: { key, label }, { key: null } for Escape;
plugins.manage).
The command palette
The official palette plugin opens with ⌘⇧P (commands: fuzzy over the title
and the category, recently run first, each with its key and plugin, those
whose when is false hidden; a command whose default key another command
has shows "Conflict" and Assign, which opens Keyboard Shortcuts searched
for it through the convergence/settings service's showShortcuts) and
⌘P (chats by title and workspace, the most recently active first; >
there lists commands). A plugin adds a mode:
api.palette.provider({
id: "files", prefix: "@", title: "Files",
search: async (query) => [{ id: "src/main.rs", title: "main.rs", detail: "src" }],
run: (item) => api.services.get("convergence/tabs").open({ kind: "file", workspaceId, path: item.id }),
});
Typing the prefix in ⌘P searches the mode; run gets the item the user
picked, in the plugin that offers it, with its permissions. The palette
reaches providers through kernel/palette.providers, palette.search { plugin, id, query } and palette.run { plugin, id, item }: free for a
plugin's own, commands.run for another's.
Agents cannot run commands. A plugin with both commands.run and
tools.provide can offer commands as tools (tools.md); its enable card
shows both, so a user who wants that installs it on purpose.
Settings
A plugin declares its settings; the settings page draws them as the
plugin's own section, with its commands and keys and its permissions
(plugins/examples/hello-settings has one of each type):
"contributes": {
"settings": {
"title": "PR tools",
"properties": {
"serverUrl": { "type": "url", "default": "https://api.github.com", "title": "API server", "description": "…" },
"token": { "type": "secret", "title": "Access token" },
"maxResults": { "type": "number", "default": 20, "minimum": 1, "maximum": 100 },
"mode": { "type": "select", "options": ["fast", "full"], "default": "fast" },
"labels": { "type": "multiSelect", "options": ["bug", { "value": "feature", "label": "Features" }] },
"cacheDir": { "type": "path", "kind": "folder" },
"accent": { "type": "color" },
"enabled": { "type": "boolean", "default": true }
},
"view": "settings.js"
}
}
- Types:
boolean,string,number(minimum,maximum),selectandmultiSelect(options),path(kind:fileorfolder; kept absolute,~/expanded),url(http, https, ws or wss, no user name or password),host(a whole host name, orlocalhost:<port>),color(#rgb,#rrggbb,#rrggbbaa),secret. Each takestitle,description,defaultandplaceholder. The manifest check refuses a default that does not fit its type, and a secret with a default. viewis a module of the plugin: itsrender(ctx, api)(or default) export draws the custom part of the section, below the fields (kernel/settings_view { plugin }gives the settings page its view id).- Values live in
<data dir>/plugin-settings/<id>.json(the id with/as__), never in the plugin's folder, so they are never published and an update does not replace them. After an update the values of keys that still exist stay, new keys take their defaults, and gone keys are dropped; a value that no longer fits its type goes back to the default, with a notice in the section. api.settings.get(key)gives one value (null when unset and without a default),get()all of them; both are copies, read at once.onChange(fn)callsfn(values)after every change and returns a function that stops it; views draw again by themselves.set(key, value)writes one (nullgoes back to the default) and resolves the value kept. None needs a permission (settings.own).- A secret never reaches the values: the settings page draws the kernel's
native field (
ui.secretInput), and what the user types goes from the kernel straight into the system credential store (the Keychain, the Secret Service, the Credential Manager), for this plugin and key. Only this plugin reads it:await api.settings.secret(key). A bug report's configuration carries settings, never secrets.
Settings in permissions
A permission can take a setting's value as one whole scope entry:
"permissions": {
"net": { "hosts": ["${settings.serverUrl}"], "reason": "Talk to your API server" },
"process": { "programs": ["gh", "${settings.binary}"], "reason": "Run the GitHub CLI you choose" }
}
- Only a typed setting fills a permission:
urlorhostfornet(a URL gives its host,localhost:<port>for a loopback one),pathforfs.readandfs.write(a folder gives<folder>/**), and forprocessaselectwhose options are program names or apathofkind: "file", whose absolute path is then what may run (an interpreter is refused, as always). The template is the whole entry:*.${…}or/Users/${…}fail the manifest. - Grants store the value, parsed, not the template. A value the user did not approve is not granted, whoever wrote it (an edit of the file by hand or by an agent asks for a new grant, which waits for "Review permissions…"); an official plugin gets only its default without a card.
- Only the user changes such a setting:
api.settings.setrefuses it. When the user saves a new value that needs a grant, the app shows its change card ("API server changed: api.github.com → api.example.com"), and the old grant stays until the user answers; Cancel puts the old value back. - A value that does not parse (a URL with a password in it, a path with
..) asks for nothing: the permission is off, and the section says why. - An unset setting (no value, no default) asks for nothing.
Tools, hooks and agents
Tools every agent can call: api.tools.register, in tools.md.
Hooks
api.hooks.on("before_prompt", ({ text, context }) => ({ text: `${text}\n\nAnswer in French.` }));
api.hooks.on("after_turn", ({ outcome, context, workspaceId, changedPaths }) => { /* ... */ });
api.hooks.on("tool_call", ({ call, context }) => { /* ... */ });
A manifest entry is a hook name or { "hook": "tool_approval", "order": -10 }.
The default order is 0; lower orders run first, with ties by plugin name.
The host calls only handlers whose manifest lists the hook and whose grants cover it.
api.hooks.on returns an unsubscribe.
| Hook | Mode | Permission | Result |
|---|---|---|---|
before_prompt |
waterfall, each receives the previous text | chats.control |
string or { text }; nothing keeps the text |
after_turn |
parallel notification | chats.read |
ignored |
tool_call |
parallel notification | chats.read |
ignored |
tool_approval |
ordered gate | approvals.auto (dangerous) |
{ decision: "allow" | "deny" | "ask", reason? } |
after_turn receives { outcome, context, workspaceId, changedPaths, itemId };
changedPaths and itemId are null without a checkpoint. tool_call receives
{ call, context }. Notification handlers log errors and ignore answers.
api.hooks.on("tool_approval", ({ approval, chatId, workspaceId, agentId }) =>
({ decision: "deny", reason: "Workspace policy" }));
Every gate handler runs in order. Any deny wins; otherwise the first allow wins,
and with no allow the user decides. Throws, malformed answers and handlers that
take over 10 seconds count as ask and are logged. The gate never fails open.
Automatic answers use the first allow_once or reject_once option; if that
option is absent or answering fails, the user sees the approval card.
The gate runs outside the event loop while later events of that session wait
in order. Automatic decisions are persisted as notices with approval metadata
(id, decision, decidedBy, reason?) and shown as “Allowed by <plugin>” or
“Denied by <plugin>: <reason>”.
Agents
api.agents.register({
id: "echo", name: "Echo",
initialize: () => ({ id: "echo", name: "Echo", capabilities: {}, authMethods: [], status: { state: "ready" } }),
create_session: () => ({ sessionId: `s${Date.now()}` }),
prompt({ sessionId, input }, { emit }) {
emit({ sessionId, event: "text_delta", itemId: "a1", text: input.blocks[0].text });
emit({ sessionId, event: "run_finished", outcome: { status: "completed" } });
return { runId: "r1" };
},
});
The host calls the handler named after each agent/<method> of
agent-protocol.md (create_session or createSession), with the params
and { agentId, emit }; emit(event) sends an agent event. A method the
agent has no handler for fails with "does not answer". Agents registered
in activate are listed once the plugin has loaded; ones registered or
removed later (accounts read from storage, api.agents.unregister) reach
the host by themselves: the runtime tells it (host/agents.changed) and
it lists them again, serving the new ones and stopping the removed ones.
Needs agents.provide, and the id must not be another plugin's.
Models and subagents
A plugin with models.use runs prompts through the user's own enabled
agents: no API keys, and the user's accounts pay. The host creates a
hidden session for each prompt (no chat, no plugin tools, so a subagent
cannot start subagents without end), and closes it after.
const { agents } = await api.models.list();
// [{ id, name, icon?, model, effort, models: [{ value, name, group?, efforts: [{ value, name }] }], error? }]
const answer = await api.models.prompt({
agentId: "codex", model: "gpt-5.5", effort: "high", // model and effort: the agent's default without them
prompt: "Summarize src/lib.rs", // or blocks: [...]
workspaceId, // the chat's, the focused chat's, or the first workspace without it
onEvent: (event) => {}, // every agent event of the run (agent-protocol.md)
signal, // abort: the host cancels the run and closes the session
subagent, // a ctx.subagent handle: the run shows in it
});
// { text, usage, status: "completed" | "cancelled" | "failed", message?, agentId, model, effort }
text is the agent's last message. A model or effort the agent does not
offer fails with the ones it does. With subagent (a handle from a tool
call's ctx.subagent.start, tools.md) the host shows the run in that
subagent as it streams, the subagent's stop button stops the run, and the
run's approvals and questions go to the user's cards in the chat, marked
with the subagent. Without one, nobody can answer them: the host refuses
an approval and dismisses a question.
A subagent type is a kind of subagent the main agent can start by name
through the subagents plugin's spawn_subagent tool (its type):
api.subagents.defineType({
id: "reviewer", title: "Reviewer", description: "Reviews the change for bugs",
inputSchema: { type: "object", properties: { focus: { type: "string" } } },
async run(input, ctx) { // input: { prompt, ...what the agent passed }
const sub = await ctx.subagent.start({ title: "Review", agent: "claude" });
const r = await api.models.prompt({ agentId: "claude", prompt: input.prompt, subagent: sub });
sub.end({ status: r.status, summary: r.text });
return r.text; // the tool's result
},
});
ctx is a tool call's (tools.md): the subagents it shows nest under the
agent's spawn_subagent call. Types are listed with the plugin's tools
(tools/list answers subagentTypes); api.subagents.types() lists every
plugin's, [{ plugin, id, title, description, inputSchema }], and
api.subagents.run({ type, input }, ctx) runs one (id, or plugin/id
when two plugins use the id) and resolves with what its run returned.
Composer mentions
A plugin offers entries for the composer's @ picker, next to files:
api.mentions.set([
{ token: "codex/gpt-5.5/high", label: "Codex · GPT-5.5 · High", detail: "Subagent on GPT-5.5",
icon: "bot", keywords: "codex gpt high",
prompt: "The part tagged {token} is for a subagent: call spawn_subagent with agent \"codex\" ... The part: {part}" },
]);
api.mentions.list(); // every plugin's, each with its `plugin`
set replaces the plugin's list (at most 2000 entries reach the others);
the host pushes every plugin's to every plugin. The chat plugin shows the
matching ones after @; a chosen one is @<token> in the text, one
segment (deleting into it deletes it all) with a chip that names it. On
send the message carries each tag's prompt after the text, with
{token} and {part} (the text after the tag up to the next one, or the
text before it when it ends the message) filled in. The official subagents
plugin offers one entry per agent/model/effort of the enabled agents.
Services
Plugins talk to each other through typed services: a provider owns a
service (publisher/name, declared in the manifest's provides with the
contract module that defines it), a consumer declares it in inject
(needed: the plugin waits, shown as Waiting, until a provider runs a
matching version) or optional (used when it is there). One plugin is the
provider of a name; when two offer one, the user's choice (the
serviceProviders setting) wins, then an official plugin, then the first
by name.
// The provider: the contract is JSON Schema 2020-12. `impl[method]`
// answers `services/call` with `(params, { caller, signal })`.
const svc = await api.services.provide(
{ name: "alice/board", version: "1.0.0", access: "any", // or "official", or ["alice/ui"]
json: { methods: { open: { params: { type: "object" }, result: { type: "object" } } },
events: { changed: { type: "object" } } } },
{ open: async (params) => ({ ok: true }) },
);
svc.emit("changed", { n: 1 }); // to the subscribed declarers
throw svc.error("NoBoard", { id }); // a declared error (list it in `errors`)
// The consumer (declares alice/board in `inject` or `optional`):
const board = api.services.get("alice/board");
await board.open({ id: "x" }, { signal }); // params validated by the host
const off = board.on("changed", (payload) => {});
api.services.when("alice/board", (client) => { /* a provider came */ return () => {}; });
The host enforces the contract: params, results, event payloads and
errors are validated against the schemas, a caller must have declared the
service and be allowed by the provider's access, and failures come back
as errors named for their tag (ServiceUnavailable, NotAllowed,
InvalidParams, ContractViolation, Interrupted). A provider that
reloads holds calls for at most a second; a consumer whose inject
provider goes away returns to Waiting.
Limits
A view may draw at most 50 000 nodes and hold at most 20 000 canvas commands; a node may carry at most 4 shadows and 1 MiB of text. A view over a limit shows what went over instead of its tree, until it is under again. Its content is clipped to the view (with 8 px to spare at the edges, for a badge on a button); popovers and overlays are drawn by the kernel, above the view and below the security cards.
A plugin may use 512 MiB of memory (official plugins) or 256 MiB (every
other), and run JavaScript for at most 10 seconds at a time. A plugin that
goes over either is stopped at once, whatever its try/catch does: its
views go, and Settings > Plugins shows it failed with Stopped: too much memory or Stopped: busy loop and a "Start again" button
(plugins.reload). It is not restarted by itself. Long work waits on the
host (await) instead of looping.
Events
api.on(name, fn) receives the JSON from events.md. Async handlers are
fine; errors are logged. A plugin gets only the events it holds the
permission for (events.md): the host does not send it the others.
Permissions
A plugin runs only after the user enabled it, and it can do only what its
manifest asks for and the user granted (see ../AGENTS.md). The kernel and
the host check each call:
- Host calls need the permission
host-api.mdlists for the method; so doapi.fs,api.net,api.processandapi.env, whose calls are host calls. - Kernel calls:
pick_folder,sleep,pick_images,image_data,forget_image,themesandcommandsneed nothing.attach_imageneeds nothing for bytes, or for a path the kernel gave this plugin (a pick, a drop, a paste); any other path needsfs.readcovering it.theme,keybindings.changeandkeybindings.openneedsettings.write;keys.recordandkeys.record_cancelneedplugins.manage.context.set,palette.providersandsettings_viewneed nothing.run_command,palette.searchandpalette.runneed nothing for the plugin's own command or palette mode andcommands.runfor another plugin's.terminal.*needsterminal.plugins,reload_plugin,set_plugin_enabledandquitneedplugins.manage. An unknown method fails withunknown kernel method <method>. - Views in the shell slots (
left,center,detail,right,rail,bottom,status,title) needui.slotsnaming that slot. Without it the view is not placed, and Settings > Plugins shows the error. A view in any other slot (embedded withui.view) needs nothing. api.openUrl(url)opens onlyhttp,httpsandmailtolinks, and only within five seconds after the user clicked, typed, dropped or pasted in one of the plugin's views, when the plugin'snetgrant covers the link's host, or (anhttporhttpslink of a plugin withagents.provide) while the user signs in to one of its agents: from the moment the host calls the agent'sauthenticateuntil it answers. A provider has no view to click in, and the sign-in is the user's own action. Otherwise the link stays closed, a notice says why, and the call resolves{ opened: false, reason }. A popover dismissed by a click elsewhere, a timer, and window callbacks do not count as the user's action.
A refused call rejects with an Error whose name is
PermissionNotGranted and whose permission and scope say what was
missing; its message reads like PermissionNotGranted: files has no grant for fs.read /etc/hosts. NeedsReview is the name when a plugin can only
be enabled from its card (plugins.setEnabled). Every other failure is
named Error:
try {
await api.host.fs.read({ path: "/etc/hosts" });
} catch (e) {
if (e.name === "PermissionNotGranted") api.notify(`Needs ${e.permission} ${e.scope ?? ""}`, "warning");
else throw e;
}
Managing plugins
plugins.manage is for official plugins only (the settings page). It
gives api.host.plugins.* (see host-api.md): list, requestEnable({ name, chatId? }), setEnabled({ name, enabled }), revoke({ name, permission, scope? }) and reload({ name }). None of them grants
anything. requestEnable asks the app to show its native card, and only
the user's answer on that card enables a plugin or grants it more:
setEnabled turns a plugin off, or back on when it was enabled before and
asks for nothing new; otherwise it rejects with NeedsReview and the page
calls requestEnable. The host loads or unloads the plugin when it is
turned on or off (access_changed follows), and reload loads it again.
Tool renderers
api.toolRenderer({ kind: "execute", render: (call) => ui.column({}, [ui.code(call.title, "bash")]) });
call is a ToolCall (see agent-protocol.md); a renderer matches by
name (the tool's name) or kind. A plugin draws another plugin's tool
calls with ui.toolView({ call, id }): the kernel asks the plugin that
registered a renderer for the call to render it, in its own runtime, and
draws that plugin's tree in place (its callbacks go to that plugin). id
tells two cards of one tree apart; a card goes when the tree no longer
places it. api.toolRenderers() lists what every plugin renders, and the
chat plugin uses ui.toolView for those calls and its own card for the
rest.
Cleanup
What the runtime registered for a plugin goes when it unloads or reloads: its views, listeners, commands, tools, hooks, agents and timers. What the plugin started elsewhere does not: a program it spawned, a watch or a stream it opened through the host, state it shared. It takes those down itself:
export function activate(api) {
const child = startServer(api);
api.onUnload(({ reloading }) => child.kill());
// Or: return the cleanup, or a Promise of it (an async activate).
return () => stopPolling();
}
api.onUnload(fn) registers fn; the function it returns removes it.
activate may return a function, or a Promise that resolves to one, and
it is registered the same way. On unload the handlers run first, while
everything the plugin registered is still there, the last registered
first, each with { reloading } (true when new code loads next). A
handler that throws is logged and the others still run. Handlers are
synchronous: a Promise one returns is not waited for. A cleanup that an
async activate resolves to after its plugin was unloaded runs at once.
Console and errors
console.log/warn/error go to the app log. An exception in activate
fails the plugin and shows the error in Settings > Plugins. An exception
in a view shows the message in that view's slot. A call refused for want of
a grant rejects as described under Permissions; the rest of the plugin
keeps running. A promise that fails with nothing to catch it is logged.
Tabs in the detail pane
The tabs plugin provides the convergence/tabs service (declare it
optional with "^1"): open(tab) opens a tab and answers { id },
close({ kind, path }) closes one, active() answers the visible tab
({ kind, path }, or null), and the activeChanged event follows it
({ kind, path } | null). Kinds: file (workspaceId, path), diff
(workspaceId, path, diff), terminal (workspaceId, path: id, title)
and view (workspaceId, path: key, title, icon?, view): view is the id
of a view the calling plugin registered with api.slot under a slot
nothing else draws, and the tab draws it with ui.view. The chat plugin
opens each subagent and the Agents roster this way. Without the tabs
plugin a call fails ServiceUnavailable; official consumers treat that
as no detail pane and do nothing.
// The consumer (declares convergence/tabs in `optional`):
const tabs = api.services.get("convergence/tabs");
await tabs.open({ kind: "file", workspaceId, path: "src/main.ts" });
api.services.when("convergence/tabs", (client) => {
if (!client) return;
const off = client.on("activeChanged", (tab) => {});
return off;
});
The shared openTab/closeTab keys still open and close tabs, but they
are deprecated for third-party plugins: they exist so plugins written
against them keep working, and official plugins use the service instead.
activeTab/activeTabPath are gone; follow activeChanged (and read
active() once) instead.
Source: plugins/docs/ui-plugins.md in the Divergence repository, built with this site.