Integrations

Plugin API

Plugin API

This is the authoring reference: the manifest, the host API your code talks to, and the versioning contract. If you just want to install and run plugins, read Plugins instead.

The single biggest predictor of a plugin ecosystem’s survival is a stable, versioned API surface. So this document leads with a changelog and treats every entry as a promise.


API changelog

API 1.0

The day-zero surface.

  • Capabilities (live): op-hook, slash-command, read-only config-schema, keybinding, toolbar-button, ui-render, and content-transformer:text / :rich. keybinding fires the bound command from a chord on the TUI (Normal mode, single + two-chord, never overriding a native binding) and the desktop (a native binding always wins); mobile has no keyboard. toolbar-button renders a chrome button on desktop and mobile, and surfaces the command in the TUI slash menu (a terminal has no chrome bar). ui-render and content-transformer:rich run author-written HTML/JS in a sandboxed iframe on the GUI clients (desktop + mobile); the TUI/CLI drop HTML. content-transformer:text renders on every read surface (inline in the TUI). sync-transport is core-ready (the host serializes and applies ops through a registered {push, pull}) but no client polls it yet — see Roadmap.
  • Host namespaces (live): ctx.ops, ctx.blocks, ctx.page, ctx.commands, ctx.config, ctx.content, ctx.storage, ctx.net, ctx.sync, ctx.log, ctx.ui. (ctx.sync.register is live; the client polling that drives it is roadmap.)
  • Permissions: read-page, write-page, read-op-log, submit-op, storage:local, network:<domain>.
  • Entry contract: definePlugin({ activate(ctx), deactivate?() }).

Anything not listed here is not part of API 1.0, even if a capability string for it exists in the manifest schema (see Versioning).

The SDK types describe the API 1.0 target; the runtime ships a subset. @outl/plugin-sdk types every read/write as async / Promise. The runtime today is synchronous (describe → apply: reads return from a turn snapshot, writes are buffered and applied when your handler returns — see Plugin architecture). Write against the typed contract; the Host API table below is the authority on what actually runs.

Gas: the engine can’t be wedged by a runaway plugin. Boa runs under RuntimeLimits — a loop-iteration cap (~20M), a recursion cap (~2000), and a stack-depth cap. An infinite loop or unbounded recursion surfaces as a JS error, not a hung thread. This is cooperative gas against a misbehaving plugin, not a wall-clock timeout.


Anatomy of a plugin

A plugin has two shapes: the dev layout (your repo) and the installed layout (what lands in a workspace).

Dev layout (your repo)

outl-todo-archiver/
├── plugin.json             # contract with the host (manifest)
├── package.json            # build deps + SDK (NOT shipped)
├── tsconfig.json
├── config.schema.json      # JSON Schema for user config
├── src/
│   ├── index.ts            # entry: calls definePlugin(...)
│   ├── commands/
│   │   └── archive-done.ts
│   └── hooks/
│       └── on-op.ts
├── README.md
└── LICENSE

You build with esbuild / tsup into one bundled index.js — no node_modules, no runtime resolution.

Installed layout

.outl/plugins/com.avelino.todo-archiver/
├── plugin.json
├── index.js                # bundled, single JS file
├── index.js.map            # optional, for errors
├── config.schema.json
└── README.md

Only the build output ships. The rule is hard: a plugin survives deleting node_modules.

Dev mode lives in _dev/<name>/ — hot reload, implicit permissions, a “sandbox relaxed” banner, and excluded from sync.

Scaffolding all of the above is one command:

outl plugin init

It generates the manifest, tsconfig, the SDK wiring, an example src/index.ts, and the bundler config.


The manifest — plugin.json

A single JSON file at the plugin root. It’s validated against plugin-v1.json at install and on every load; point your editor at that $id for autocomplete.

FieldRequiredDescription
idReverse-DNS identity (com.avelino.todo-archiver). Never changes across versions. Used as the install directory and the op-log actor stamp plugin:<id>@<device>.
nameHuman-readable display name.
versionPlugin version (semver). Resolved from the install tag, frozen in the lockfile.
apiPlugin API range this plugin targets, e.g. ^1.0. Matched against the host’s plugin API, not the binary version.
mainPath to the bundled entry file, e.g. index.js.
engines.outlMinimum binary version (semver range), e.g. >=0.8.0. Independent of api.
capabilities[]What the plugin plugs into (see below).
permissions[]What it requests against the host (see below).
contributes.commands[]{ id, title, description? } — surfaced in the slash menu / palette.
contributes.keybindings[]{ command, key, when? } — default chord bindings for those commands.
contributes.configSchemaPath to a JSON Schema file (config.schema.json) for the user-editable config form.
metadataauthor, license, repo, funding, locales, category, description. Descriptive only.

id — reverse-DNS

The id is identity, not a label. It’s the directory name under .outl/plugins/, and it’s what stamps the op log, so it must be stable forever and unique across the ecosystem. Pattern: lowercase reverse-DNS, e.g. com.avelino.todo-archiver.

capabilities[]

The loader intersects what you declare with what the running client implements (see Plugins → Capabilities per client). A capability the client can’t honor lands in a warning, and the plugin still loads for the rest.

Live capabilities: op-hook, slash-command, keybinding, config-schema (read), toolbar-button, ui-render, and content-transformer:text / :rich. sync-transport is core-ready (the host serializes/applies ops through a registered transport) but no client polls it yet. A plugin that wants to be a query engine registers a content-transformer for the query fence language (```query); {{query}} inline would need a new markdown token the parser defers, so there is no separate query-provider capability.

permissions[]

Declared here, approved by the user on install, frozen in the lockfile. Every host call is gated against the approved set, so a permission you didn’t request is a permission your code can’t use.

"permissions": ["read-page", "submit-op", "network:*.openai.com"]

Network must be scoped to a domain. network:api.openai.com and network:*.openai.com are valid; a bare network:* is rejected at parse time.


definePlugin

The entry file exports a single definePlugin(...) call. Metadata redundant with plugin.json stays in the manifest; the entry file is behavior only.

// src/index.ts
import { definePlugin, type PluginContext, type LogOp } from "@outl/plugin-sdk";

export default definePlugin({
  activate(ctx: PluginContext) {
    // 1) op-hook — runs on every applied op (local or from sync)
    ctx.ops.onOp((op: LogOp) => {
      if (op.kind === "Edit" && op.text?.startsWith("DONE ")) {
        ctx.log.info(`block ${op.node} completed`);
      }
    });

    // 2) command — fired by the "/" slash menu / palette (CLI: `plugin run`)
    ctx.commands.register("todo-archive-done", () => {
      const cfg = ctx.config.get<{ archivePage: string }>();
      const done = ctx.blocks.query({ todo: "DONE" });
      for (const b of done) {
        ctx.blocks.move(b.id, { toPage: cfg.archivePage });
      }
      ctx.ui.notify(`${done.length} blocks archived`);
    });
  },

  // optional: cleanup on disable / update
  deactivate() {},
});

activate(ctx) runs once when the plugin loads. deactivate() is optional and runs when the plugin is disabled or updated.

Mutation never happens in JS. ctx.blocks.move becomes a host call → outl-actions::block::*Workspace::apply → an op log entry stamped plugin:<id>@<device>. You think in blocks and ops; the host owns the CRDT and the .md.

Hooks and command handlers run under a re-entrancy guard (the host tracks how far into the op log it has dispatched), so a plugin that triggers ops that trigger the plugin again can’t spin into an infinite loop. There is no ambient I/O — only what a permission grants.


Host API — PluginContext

This table is the canonical owner of the runtime host-API surface. The architecture and tutorial link here rather than restating it. Every namespace is gated by the permission in the right-hand column. The typed signatures live in @outl/plugin-sdk; the runtime ships the subset below (the SDK types reads/writes as Promise, but the runtime resolves them synchronously within the turn — see the describe → apply note).

NamespaceFunctions (what runs today)Permission
ctx.opsonOp(cb: (op: LogOp) => void)read-op-log
ctx.blocksquery(filter) → Block[], get(id) → Block, edit(id, text), create(parentId, text), createAfter(afterId, text), move(id, { toPage } | { toParent }), toggleTodo(id), delete(id)read-page (reads) · write-page (writes)
ctx.pagelist() → { slug, title, kind }[], create(slug)read-page (list) · write-page (create)
ctx.commandsregister(id, handler)— (declared in contributes.commands)
ctx.configget<T>() → T
ctx.contentregister(lang, fn)fn(body) → { kind: "text" | "rich", content } | null renders a fenced block of language lang (e.g. ```query)capability content-transformer:text / :rich
ctx.storageget(k) → v | null, set(k, v), delete(k) — per-plugin KV at <workspace>/.outl/plugins/<id>/storage.json, local-only (never converges)storage:local
ctx.netfetch(url, { method?, headers?, body?, timeoutMs? }) → { ok, status, headers, text(), json() } (or { ok: false, error } on a denied domain — it returns, it doesn’t throw); blocking on the plugin threadnetwork:<domain>
ctx.syncregister({ push(opsJsonl), pull() → jsonl | null }) — register a sync transport (core live; client polling is roadmap)capability sync-transport
ctx.loginfo(m) / warn(m) / error(m)
ctx.uinotify(m)
ctx.uirender(html) — run author-written HTML/JS in a sandboxed iframe overlay (GUI only)capability ui-render

Block is { id, text, todo?, page }text has the TODO/DONE prefix stripped, and todo is "TODO" \| "DONE" or absent. LogOp (what onOp receives) is { kind, node, text?, todo? }, where kind is one of "Create" | "Move" | "Edit" | "SetProp" | "SetCollapsed"; text and todo are present only on "Edit".

A few load-bearing notes:

  • ctx.ops.onOp receives a LogOp that has already been applied — local edits and ops arriving from sync alike. Use it to react, not to gate; you can’t veto an op. Your own writes never re-fire your hook (the host advances its log mark past them).
  • ctx.blocks splits by permission — reading (query/get) needs read-page; every mutating call (edit/create/createAfter/move/toggleTodo/delete) needs write-page. A plugin granted only read-page can read but every write is dropped with a recorded error, never a crash.
  • Reads are a turn snapshot, writes are deferred. query/get read the workspace as it was at the start of the turn; a write you emit is not visible to a later read in the same handler — it lands on the next turn. This is the describe → apply model (architecture).
  • ctx.config.get<T>() returns the plugin’s config from the lockfile’s config field as-is. Schema validation of that config (against configSchema) is not enforced yet, so treat T as a shape you trust, not one the host guarantees.
  • ctx.commands.register wires a handler to a contributes.commands[].id, surfaced in the slash menu / palette / mobile sheet, or run headless via outl plugin run <id> <command>.
  • ctx.content.register(lang, fn) registers a transformer for a fenced block language: fn(body) returns { kind: "text" | "rich", content } or null to decline. text renders on every read surface (inline in the TUI); rich is HTML in a sandboxed iframe inline in the block and runs on the GUI clients only — the TUI/CLI drop a rich descriptor. A query engine plugs in here by registering for the query language.
  • ctx.storage.{get,set,delete} is per-plugin key/value persisted at <workspace>/.outl/plugins/<id>/storage.json. It’s local-only and deliberately outside the op log — it does not converge between devices. Without storage:local the call throws a clear error.
  • ctx.net.fetch(url, opts) is blocking on the plugin thread (on the TUI it blocks the UI for the duration of the request, bounded by timeoutMs). A domain the manifest didn’t grant returns { ok: false, error } rather than throwing. network:<domain> gates it; a bare network:* is rejected at parse time (use domain or *.domain).
  • ctx.sync.register({ push, pull }) registers a sync transport: the host serializes local ops into push(opsJsonl) and applies whatever pull() returns through Workspace::apply. The core path is live and convergence is tested; what’s missing is a client that calls push/pull on a timer — see Roadmap.

Roadmap (not yet available)

These are typed in @outl/plugin-sdk and/or enumerated in the manifest schema so the contract is forward-stable, but the runtime does not drive them end to end yet.

SurfaceState todayNotes
sync-transport client pollingCore live, no client driverctx.sync.register works and convergence is tested, but no client calls push/pull on a timer yet.
ctx.page.open(slug) / ctx.page.today()Not presentTyped in the SDK; the runtime ctx.page exposes only list/create.
{{query}} inlineParser defers itA fenced ```query block already works through a content-transformer; inline {{query}} needs a new markdown token the project defers.

github: install and outl plugin init ship today (see Plugins → Installing). The remaining tooling roadmap (outl plugin update, .outlpkg pack, dev hot-reload, a dev console, a config-editing form UI, and discovery / marketplace / signing in the clients) is tracked in Plugins.


Versioning

There are two independent semver axes, and keeping them separate is what lets the plugin API stay stable while the binary moves fast.

  • api — the plugin’s required range against the plugin API surface (the host API + capabilities described here), e.g. ^1.0. A plugin asking for api: "^2.0" on a host that only implements API 1.x does not load, with an error pointing the user at “update outl or use the previous plugin version”.
  • engines.outl — the minimum binary version, e.g. >=0.8.0. This tracks the fast-moving binary; api tracks the slow, long-lived contract.

A plugin built against API 1.0 keeps working on every host that still implements API 1.x, regardless of how many binary releases ship in between.

Changelog discipline

The API changelog at the top of this page is the contract. When the host adds a namespace, capability, or permission, it gets a new entry there before it ships, and anything not yet listed is not part of the stable surface. Treat each entry as a promise: things in API 1.0 don’t break under API 1.x.


See also

  • Plugin tutorial — build the TODO-archiver end to end.
  • Plugin architecture — describe → apply, the host, permission gating, the lifecycle.
  • Plugins — installing, permissions, distribution, the lockfile.
  • plugin-v1.json — the manifest JSON Schema.
  • CLI — the outl plugin subcommands.