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.process and api.env (below);
  • the app's data through api.host.* (host-api.md);
  • the window through api.host.kernel(...), api.notify, api.openUrl and 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) and gridColumn on 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 for margin (which also takes "auto"): numbers, or the steps none|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, border and borderTop|Right|Bottom|Left ({ width, color }), borderRadius (sm|md|lg|xl|xxl|full or 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 or xs|sm|base|lg|xl|2xl), fontWeight (a number or normal|medium|semibold|bold), lineHeight, textAlign, lineClamp, whiteSpace (normal|nowrap), truncate.
  • States: hover, active, focus and disabled hold paint and text props (background, color, opacity, border, borderColor, shadow, fontWeight, visibility, underline) that apply in that state; disabled applies while isDisabled is true, which also stops the div's events. group: "name" names a hover group, and groupHover: { 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 (with overflowY: "scroll"), reveal (with overflowX: "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 takes fill, stroke and lineWidth to 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, y its 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 }) — badge is a short count drawn on the button's corner accent is 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 }; secondary means ⌘ on macOS and Ctrl elsewhere. Multiline submits on ⌘⏎/Ctrl+Enter, or on ⏎ with submitOnEnter (⇧⏎ 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. ghost drops the frame (only for an input inside a surface that already frames it: the composer, a picker's search); icon draws 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: true gives 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) }) — optional svg markup appears in both the trigger and menu rows
  • ui.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 (select is drawn as one). look: field (default), compact, toolbar (a frameless label, the composer's pickers) or row (an item'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 an icon mark: a workspace's folder-solid): group puts consecutive rows under a heading that folds (shown with two or more groups), starred shows a star reporting onStar({ value, option? }), detail is muted text at the row's end, and submenu ({ value, options, search?, width?, empty?, context? }) opens a list beside the row whose choice reports onChange({ value, option }) with option the row's value; a context submenu 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, all names 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 shows label (else the chosen labels, else placeholder), then detail fainter, after marks (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) }) — segmented draws a segmented control (a few choices of one setting) instead of a tab bar
  • ui.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), stacked putting the mark level with the label and the second line under the mark too (a chat row), and actions ([{ icon, tooltip, onClick }]) over its right edge while the pointer is on it. A <slot name="leading"> child replaces the icon. With onIconClick the mark is a button of its own, showing hoverIcon only 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; divided draws 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 when stacked).
  • <callout tone title text icon>actions</callout> — a message with a tone (info, success, warning, danger).
  • <progress value tone /> — a bar; no value for 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; while open, the content floats below it over everything else (taking no space) and a press outside it or Escape calls onDismiss. The plugin owns open, so the anchor toggles it and onDismiss clears it. A press on the anchor itself is not "outside", so the toggle is a real toggle. Give the anchor onPress rather than onClick: 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, top of 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. While open it holds the keyboard: it takes focus and gives it back when it closes, keys maps 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 calls onDismiss. hold names a modifier (ctrl, alt, shift, cmd) whose release calls onRelease, which is how a Ctrl+Tab cycle commits. The kernel draws the card (surface, border, shadow, radius); the content is yours. The plugin owns open.
  • 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 pair
  • ui.list({ id, fill, items: [{ id, label, sublabel, icon, tone, strike, selected, badge, onClick, actions: [{ icon, tooltip, onClick }] }] })
  • ui.icon(name, { small, tone, svg }) — svg draws inline SVG markup in place of the named icon (currentColor follows the text colour, currentMuted the 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 with badge (its letter) at the end, hint as faint text after the name, and actions while the pointer is on a row. Give files: [{ path, label, status, badge, hint, actions }] instead of items and the kernel makes the folders (openFolders opens them all; a folder's id is its path with a /). fit makes 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 }) or ui.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. terminal is the id terminal.create returned. 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) });
  • id is <plugin name>.<command>. The manifest declares every command api.command registers (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 the when are the manifest's; what the code passes besides id and run is ignored.
  • key is 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).
  • when is 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 sets composerFocused (the chat composer, the input with catchTyping, has the keyboard), terminalFocused, transcriptFocused (the center shows a chat and no input, terminal or overlay has the keyboard), workspaceOpen and overlayOpen. A plugin sets keys of its own with api.context.set(key, value), named <plugin name>.<key> (kernel/context.set; another plugin's name is refused); null removes 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.run for another's (the palette). The kernel's own keys are commands too: kernel.openSettings (⌘,), kernel.toggleSidebar (⌘B), kernel.toggleDetail (⌘⇧B), kernel.reloadPlugins (⌘⇧R) and kernel.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:

  1. The user's entry always wins.
  2. 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.
  3. A third-party default needs cmd, ctrl or alt (F1–F12 need nothing), and cmd+q, cmd+,, cmd+shift+p and cmd+p are 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), select and multiSelect (options), path (kind: file or folder; kept absolute, ~/ expanded), url (http, https, ws or wss, no user name or password), host (a whole host name, or localhost:<port>), color (#rgb, #rrggbb, #rrggbbaa), secret. Each takes title, description, default and placeholder. The manifest check refuses a default that does not fit its type, and a secret with a default.
  • view is a module of the plugin: its render(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) calls fn(values) after every change and returns a function that stops it; views draw again by themselves. set(key, value) writes one (null goes 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: url or host for net (a URL gives its host, localhost:<port> for a loopback one), path for fs.read and fs.write (a folder gives <folder>/**), and for process a select whose options are program names or a path of kind: "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.set refuses 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.md lists for the method; so do api.fs, api.net, api.process and api.env, whose calls are host calls.
  • Kernel calls: pick_folder, sleep, pick_images, image_data, forget_image, themes and commands need nothing. attach_image needs nothing for bytes, or for a path the kernel gave this plugin (a pick, a drop, a paste); any other path needs fs.read covering it. theme, keybindings.change and keybindings.open need settings.write; keys.record and keys.record_cancel need plugins.manage. context.set, palette.providers and settings_view need nothing. run_command, palette.search and palette.run need nothing for the plugin's own command or palette mode and commands.run for another plugin's. terminal.* needs terminal. plugins, reload_plugin, set_plugin_enabled and quit need plugins.manage. An unknown method fails with unknown kernel method <method>.
  • Views in the shell slots (left, center, detail, right, rail, bottom, status, title) need ui.slots naming that slot. Without it the view is not placed, and Settings > Plugins shows the error. A view in any other slot (embedded with ui.view) needs nothing.
  • api.openUrl(url) opens only http, https and mailto links, and only within five seconds after the user clicked, typed, dropped or pasted in one of the plugin's views, when the plugin's net grant covers the link's host, or (an http or https link of a plugin with agents.provide) while the user signs in to one of its agents: from the moment the host calls the agent's authenticate until 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.