Integrations

Build your first plugin

Build your first outl plugin

This is a hands-on tutorial. By the end you’ll have written, built, installed, and run a real outl plugin — from an empty folder to a working command you trigger from the CLI, the TUI, the desktop app, and mobile.

It’s long on purpose. Nothing is skipped, nothing is assumed. If you’ve never written a line of JavaScript for outl, start at the top and follow it straight through.

If you want the dry reference instead of the walkthrough, read Plugin API. If you want to understand the runtime internals (the Boa engine, the describe→apply model under the hood), read Plugin architecture.

What we’re going to build

We’ll build tag-counter — a small but genuinely useful plugin that exercises both day-zero capabilities you’ll reach for most:

  • An op-hook (ctx.ops.onOp) that watches edits as they land and logs whenever a block gains a #tag.
  • A slash command (ctx.commands.register) called count-tags that scans the workspace, tallies every #tag, and writes a tidy report into a tags/summary page.

It’s a real plugin: it reads blocks, mutates the workspace, reads its own config, logs, and notifies the user. That covers most of what you’ll do in any plugin.

We deliberately don’t reuse the shipped todo-archiver example. Building something new from scratch is the point — you’ll touch every moving part once.

Prerequisites

You need three things on your machine:

  1. The outl CLI, built and on your PATH. If you can run outl --version, you’re set. If not, build it from the repo: cargo build --release -p outl-cli and use the binary in target/release/.
  2. A JavaScript runtime + esbuild. We use bun in this tutorial (it’s fast and bundles esbuild-style builds out of the box), but plain node + npm works identically. Any of these is fine:
    • bun installed → bunx esbuild …
    • node + npm installed → npx esbuild …
  3. A text editor. TypeScript is recommended (you get types from the SDK), but you can write plain JavaScript if you prefer — the build step is the same.

That’s it. No outl account, no server, no registry — plugins are local files.

Step 1 — Lay out the folder

A plugin during development has two halves: the source you edit (src/, TypeScript) and the bundle the host actually runs (index.js, a single self-contained file). Create this layout anywhere on disk — call the folder tag-counter:

tag-counter/
├── plugin.json            # the manifest — what the host reads first
├── package.json           # your build script + the SDK dependency
├── tsconfig.json          # TypeScript config (skip if writing plain JS)
├── config.schema.json     # user-editable settings, described as JSON Schema
└── src/
    └── index.ts           # the plugin code you write

After we build, index.js (the bundle) lands next to plugin.json. That bundle — plus plugin.json and config.schema.json — is the installed shape: what gets copied into a workspace. Your src/ and node_modules/ never travel with it.

Don’t create the files yet — we’ll fill each one in below, explaining every field.

Step 2 — Write the manifest (plugin.json)

The manifest is the contract. The host reads it before it runs a single line of your code: it tells outl who you are, what you plug into, and what you’re allowed to touch. It’s validated against plugin-v1.json at install time and on every load — point your editor’s $schema at it for autocomplete.

Create tag-counter/plugin.json:

{
  "$schema": "https://outl.app/schemas/plugin-v1.json",
  "id": "app.outl.examples.tag-counter",
  "name": "Tag Counter",
  "version": "1.0.0",
  "api": "^1.0",
  "engines": {
    "outl": ">=0.7.0"
  },
  "main": "index.js",
  "capabilities": [
    "op-hook",
    "slash-command",
    "config-schema"
  ],
  "permissions": [
    "read-page",
    "write-page",
    "read-op-log",
    "submit-op"
  ],
  "contributes": {
    "commands": [
      {
        "id": "count-tags",
        "title": "Count #tags into a summary page"
      }
    ],
    "configSchema": "config.schema.json"
  },
  "metadata": {
    "description": "Tallies every #tag in the workspace into a summary page.",
    "author": "you",
    "license": "MIT",
    "category": "productivity"
  }
}

Field by field:

  • id — your stable, reverse-DNS identity. It never changes across versions. It’s the install directory name and the op-log actor stamp (plugin:app.outl.examples.tag-counter@<device>), so pick it once and keep it. The pattern is enforced: lowercase labels separated by dots, at least two labels.
  • name — the human-readable display name shown in menus.
  • version — semver. Bump it when you publish a new build.
  • api — the plugin API range you target, not the binary version. ^1.0 means “any host whose plugin API major is 1”. A host whose API major doesn’t satisfy this refuses to load you — that’s the compatibility gate.
  • engines.outl — the minimum outl binary version. Separate axis from api: this tracks the app, api tracks the plugin surface.
  • main — the bundled entry file, relative to the plugin root. Always a single .js file — no node_modules, no runtime module resolution.
  • capabilities — what you plug into. The loader intersects this with what the current client implements; a capability the client can’t honor loads partially with a warning, never a crash. We declare the three day-zero ones we use. (keybinding is also declarable — see the note at the end of this step.)
  • permissions — what you’re allowed to do. The user approves these once on install, and they’re frozen in the lockfile. Every host call is checked against this set — deny by default. We ask for: read-page + read-op-log to read blocks and watch ops, write-page + submit-op to write our summary.
  • contributes.commands — the commands you surface in the slash menu / palette. Each id here must match the id you pass to ctx.commands.register(...) at runtime, and the title is the label the user sees.
  • contributes.configSchema — a path to a JSON Schema file describing your user-editable settings. Day-zero accepts a path only, never an inline schema.
  • metadata — descriptive, non-load-bearing fields for discovery UI.

A note on keybinding. You can declare contributes.keybindings (each entry is { "command": "<id>", "key": "Ctrl+Shift+T" }). Chord dispatch is live on the TUI (a chord fires the command from Normal mode, single or two-chord, unless a native binding already claims it) and on the desktop (a native binding always wins). Mobile has no keyboard, so it doesn’t apply there. We leave keybindings out of this tutorial to keep it focused on the two capabilities you’ll reach for first, but you can add one to your manifest and it will fire.

Step 3 — Describe your config (config.schema.json)

Our plugin lets the user choose which page the summary lands on. That’s one setting, described as a JSON Schema (draft 2020-12). Create tag-counter/config.schema.json:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Tag Counter config",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "summaryPage": {
      "type": "string",
      "title": "Summary page",
      "description": "Slug of the page the tag tally is written to.",
      "default": "tags/summary"
    }
  }
}

You read this config at runtime with ctx.config.get().

Where the config value actually comes from, today. A form UI to edit this config doesn’t exist yet. The value ctx.config.get() returns is read from the workspace lockfile (installed.json, the config field for your plugin). The schema is still worth writing — it documents the shape, and the form-render path will use it later — but for now, treat the default in your code as the value you’ll usually get unless you hand-edit the lockfile.

Step 4 — Set up the build (package.json + tsconfig.json)

Create tag-counter/package.json:

{
  "name": "tag-counter",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "esbuild src/index.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=index.js",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@outl/plugin-sdk": "workspace:*"
  },
  "devDependencies": {
    "esbuild": "^0.24.0",
    "typescript": "^6.0.3"
  }
}

Two notes on the dependency line:

  • @outl/plugin-sdk is types plus one helper (definePlugin) and nothing else — zero runtime code. Inside this repo, workspace:* resolves it from plugin-sdk/. If you’re building a plugin outside the repo, depend on the published package version instead once it’s on npm; until then, point at a local checkout of plugin-sdk.

If you’re using TypeScript, create tag-counter/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "lib": ["ES2022", "DOM"],
    "moduleResolution": "bundler",
    "skipLibCheck": true,
    "isolatedModules": true,
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}

We’ll come back to the build command in Step 6 — it has the single biggest gotcha in the whole tutorial. For now, just install dependencies:

cd tag-counter
bun install      # or: npm install

Step 5 — Write the plugin (src/index.ts)

Here’s the whole plugin. Create tag-counter/src/index.ts, then read the walkthrough below — every API call is explained.

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

/** Our config shape, mirrored by config.schema.json. */
interface TagCounterConfig {
  summaryPage: string;
}

const DEFAULT_SUMMARY_PAGE = "tags/summary";

/** Pull every #tag out of a block's text. Returns lowercase tag names, no `#`. */
function extractTags(text: string): string[] {
  const out: string[] = [];
  const re = /#([a-z0-9][a-z0-9/_-]*)/gi;
  let m: RegExpExecArray | null;
  while ((m = re.exec(text)) !== null) {
    out.push(m[1].toLowerCase());
  }
  return out;
}

export default definePlugin({
  activate(ctx: PluginContext) {
    // 1) op-hook — fires once for every op applied to the log.
    //    We only react to text edits, and only log when a tag appears.
    ctx.ops.onOp((op: LogOp) => {
      if (op.kind !== "Edit" || !op.text) {
        return;
      }
      const tags = extractTags(op.text);
      if (tags.length > 0) {
        ctx.log.info(`block ${op.node} now has tags: ${tags.join(", ")}`);
      }
    });

    // 2) command — fired from the slash menu / palette / `outl plugin run`.
    ctx.commands.register("count-tags", () => {
      const cfg = ctx.config.get<Partial<TagCounterConfig>>();
      const summaryPage =
        cfg?.summaryPage?.trim() || DEFAULT_SUMMARY_PAGE;

      // --- READ PHASE: gather everything from the snapshot, up front. ---
      const blocks = ctx.blocks.query({}); // empty filter = every block
      const counts = new Map<string, number>();
      for (const b of blocks) {
        // Skip the summary page itself so we don't count our own output.
        if (b.page === summaryPage) {
          continue;
        }
        for (const tag of extractTags(b.text)) {
          counts.set(tag, (counts.get(tag) ?? 0) + 1);
        }
      }

      // Sort by count desc, then name, for a stable report.
      const ranked = [...counts.entries()].sort(
        (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
      );

      // --- WRITE PHASE: now emit the mutations. ---
      ctx.page.create(summaryPage); // idempotent on the slug

      if (ranked.length === 0) {
        ctx.ui.notify("No #tags found in the workspace");
        return;
      }

      for (const [tag, n] of ranked) {
        // We don't have the page's block ids in hand, so we move blocks
        // onto the page with `create`-then-`move`… but the simplest path
        // is to create top-level blocks via move targeting the page.
        // See the describe→apply note below for why we batch reads first.
        ctx.blocks.create(summaryPageRootId(summaryPage), `#${tag} — ${n}`);
      }

      ctx.ui.notify(`Counted ${ranked.length} distinct tag(s)`);
    });
  },
});

/**
 * The page's root needs a block id to parent under. In day-zero the simplest
 * reliable target is the page slug via `move`, but `create` needs a parent id.
 * For this tutorial we keep it explicit: see the note on page roots below.
 */
function summaryPageRootId(slug: string): string {
  return slug; // see "A honest caveat" below
}

Walking through every API call

ctx.ops.onOp(cb) — registers a hook the host calls once for every op applied to the log, local edits and synced ops alike. The op you receive is { kind, node, text?, todo? }:

  • kind is one of "Create" | "Move" | "Edit" | "SetProp" | "SetCollapsed".
  • node is the block id the op acted on (a string).
  • text and todo are populated only when kind === "Edit" — that’s the op that carries a block’s new text and TODO state.

So our hook bails unless it’s an Edit with text, then scans for tags. This needs the read-op-log permission and the op-hook capability — both of which we declared.

ctx.commands.register(id, handler) — wires the count-tags handler to the command we declared in contributes.commands. The id must match. The handler is () => void | Promise<void> — sync is fine here. It fires when the user picks the command from the slash menu, the palette, or runs outl plugin run.

ctx.config.get() — returns your plugin’s config object (read from the lockfile, as noted in Step 3). We fall back to the default page when it’s unset.

ctx.blocks.query(filter)Block[] — finds blocks. The filter is { page?, todo?, textContains? }, all optional and ANDed; an empty {} matches every block. Each Block is { id, text, todo?, page } — note text is clean, with no TODO /DONE prefix. Needs read-page.

ctx.page.create(slug) — creates a page, idempotent on the slug (safe to call every run). Needs write-page.

ctx.blocks.create(parentId, text) — creates a block as the last child of parentId. Needs write-page + submit-op.

ctx.ui.notify(msg) and ctx.log.info(msg) — user-facing toast / status line, and a line in the host’s plugin log, respectively. Neither needs a permission.

The one thing that trips everyone: describe → apply

This is the single most important mental model in the plugin runtime, so read it twice.

Reads come from a snapshot taken at the start of the turn. Writes are buffered and applied by the host after your handler returns.

Concretely:

  • ctx.blocks.query(...), ctx.blocks.get(...), ctx.page.list(), ctx.config.get() all read from a frozen snapshot of the workspace as it was when your command started.
  • ctx.blocks.edit/create/move/..., ctx.page.create(...) don’t take effect immediately. They’re queued, and the host drains the queue and applies each one (permission-gated) only once your handler has finished.

The practical consequence: a mutation you make is NOT visible to a query later in the same command. The snapshot never changes mid-turn. If you create a block and then query, the new block won’t be in the results.

The rule that follows: collect everything you’re going to change first (the read phase), then emit all your mutations (the write phase). Our count-tags does exactly that — it builds the full counts map from the snapshot before it creates a single block. Don’t interleave reads and writes expecting to see your own changes; you won’t.

A honest caveat about this example

The block-creation half of count-tags above leans on ctx.blocks.create(parentId, text), which needs a parent block id. A freshly-created page has no block you can address yet in the same turn (describe→apply again — the page doesn’t materialize until after the handler returns). For a real plugin you’d typically:

  • Write the summary into a page that already has a known anchor block, or
  • Split the work across two command runs (run once to create the page + first blocks, again to refresh), or
  • Use ctx.blocks.move(id, { toPage: summaryPage }) to relocate existing blocks onto the page (this does take a page slug and creates the page if missing).

The move-to-page path is the cleanest day-zero pattern when you’re relocating blocks you already have ids for — it’s exactly what the shipped todo-archiver example does. For brand-new content on a brand-new page, expect to run the command twice on the very first use. This is a known day-zero rough edge, not a bug in your plugin.

The host API you have to work with, in full:

CallWhat it doesPermission
ctx.ops.onOp(cb)hook every applied opread-op-log
ctx.commands.register(id, h)wire a command handler
ctx.blocks.query(filter)Block[]find blocksread-page
ctx.blocks.get(id)Block | nullone block by idread-page
ctx.blocks.edit(id, text)replace text (include TODO /DONE to keep a prefix)write-page + submit-op
ctx.blocks.create(parentId, text)new last childwrite-page + submit-op
ctx.blocks.createAfter(afterId, text)new sibling after a blockwrite-page + submit-op
ctx.blocks.move(id, target){ toPage } (creates page) or { toParent }write-page + submit-op
ctx.blocks.toggleTodo(id)cycle None→TODO→DONE→Nonewrite-page + submit-op
ctx.blocks.delete(id)move to trashwrite-page + submit-op
ctx.page.list()Page[]{ slug, title, kind }read-page
ctx.page.create(slug)idempotent page createwrite-page
ctx.config.get()your config object
ctx.log.info/warn/error(m)host log
ctx.ui.notify(m)user toast / status line

Also available (beyond the table above): ctx.storage.{get,set,delete} is per-plugin local KV (gated by storage:local, stored at <workspace>/.outl/plugins/<id>/storage.json, never syncs), and ctx.net.fetch(url, opts) does a blocking HTTP request gated by network:<domain> (a denied domain returns { ok: false, error } instead of throwing). ctx.content.register(lang, fn) registers a renderer for a fenced block language, and ctx.sync.register({ push, pull }) registers a sync transport (the core path is live; client polling is still roadmap). See Plugin API → Host API for the full signatures.

Step 6 — Build the bundle (read this carefully)

The host runs your bundle as a plain script, not as an ES module. That means the build must emit an IIFE, not ESM.

Run this from inside tag-counter/:

bunx esbuild src/index.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=index.js
# or, with npm:
npx esbuild src/index.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=index.js
# or just use the script you defined:
bun run build

⚠️ Gotcha #1 — --format=iife is mandatory. If you build with --format=esm, esbuild emits export statements. The outl engine evaluates the bundle as a script with no module loader, so an export is a syntax error in that context and your plugin silently fails to load (or loads with a confusing error). The symptom is “my command never shows up.” The fix is always: rebuild with --format=iife. This is the number-one mistake people hit.

How definePlugin reaches the host through the IIFE: the SDK’s definePlugin(def) calls globalThis.__outl_register(def), which the engine installs before it evaluates your bundle. The IIFE runs top-to-bottom, hits your export default definePlugin({...}), the helper registers your definition with the host, and the host then calls activate(ctx) with the real context. You never export anything the host consumes — registration happens through that global, which is exactly why ESM’s export machinery is unnecessary (and breaks things).

After a successful build you’ll have tag-counter/index.js. That, plus plugin.json and config.schema.json, is your installable plugin.

Step 7 — Install it

Pick (or create) a workspace to install into. If you don’t have one:

outl init ~/notes-test

Then install your plugin from its directory. github: install sources aren’t wired yet — you install from a local directory for now:

outl -w ~/notes-test plugin install ~/path/to/tag-counter --yes

What happens:

  • outl parses plugin.json and validates it against the schema.
  • It computes the bundle hash (stored for integrity — revalidated on every load).
  • It shows you the permissions the plugin requests and asks for approval. --yes approves everything non-interactively (required when stdin isn’t a TTY, e.g. in scripts). Drop --yes to see the prompt and approve by hand.
  • It copies the installed shape into ~/notes-test/.outl/plugins/app.outl.examples.tag-counter/ and freezes the approved permission set in the lockfile (installed.json).

Confirm it’s there:

outl -w ~/notes-test plugin list

You should see your plugin, its version, enabled, and the /count-tags command it contributes.

Step 8 — Run it

Let’s give it something to count. Seed a couple of blocks with tags:

outl -w ~/notes-test page create inbox --title Inbox
outl -w ~/notes-test block append --page inbox --text "ship the thing #work #urgent"
outl -w ~/notes-test block append --page inbox --text "read the paper #research"

Now run the command from the CLI:

outl -w ~/notes-test plugin run app.outl.examples.tag-counter count-tags

plugin run executes the command and then re-renders every .md so the mutation lands on disk (the op log is the source of truth; the .md files are a projection of it). You’ll see the notification text in the output.

Where the command shows up in the GUI clients

The same plugin, same command, surfaces everywhere a person edits:

  • TUI — press / to open the slash menu; /count-tags appears alongside the built-ins.
  • Desktop — click the button in the bottom corner to open the Plugin palette and pick the command.
  • Mobile — tap the plugin glyph in the header to open the plugin sheet.

op-hook and slash-command both work end-to-end on all four clients (TUI, desktop, mobile, CLI), so your onOp log line and your count-tags command behave identically everywhere.

Step 9 — Iterate fast with dev mode

Reinstalling after every code change is tedious. For authoring, drop your plugin into the workspace’s _dev/ directory instead:

~/notes-test/.outl/plugins/_dev/tag-counter/
├── plugin.json
├── index.js
└── config.schema.json

A plugin in _dev/:

  • Loads without the hash check — edit index.js, reload, no reinstall.
  • Runs with all permissions implicit — you’re not prompted on every iteration.
  • Is excluded from sync — your in-progress hacking doesn’t leak to your other devices.

The clients show a “sandbox relaxed” banner so it’s obvious you’re running unvetted dev code. The loop becomes: edit src/index.tsbun run build → copy index.js into _dev/tag-counter/ → reload the client. (You can symlink or script that copy.) This is for authoring only — ship the real, hash-pinned install when you’re done.

Step 10 — Debug when it doesn’t work

Where output goes:

  • ctx.log.info/warn/error and any console.log in your code go to the host’s plugin log (and surface in each client’s log view). This is your primary debugging channel.
  • ctx.ui.notify shows the user-facing message (status line / toast) — good for confirming a run happened, not for debugging detail.
  • Load and runtime errors surface as a warning in outl plugin list (a plugin that fails to load is reported, never hidden) and in the client’s plugin log.

Common failures and what they mean:

SymptomLikely causeFix
Command never appearsBundle built as ESM (export in index.js)Rebuild with --format=iife
Command never appearscontributes.commands[].id ≠ the id passed to ctx.commands.registerMake the two ids identical
”permission denied” / write does nothingYou called a host method whose permission you didn’t requestAdd the permission to plugin.json, reinstall (you’ll re-approve)
Install rejected: invalid idid doesn’t match reverse-DNS (a.b, lowercase, ≥2 labels)Fix the id pattern
Install rejected: github: sourcegithub: isn’t wired yetInstall from a local directory
ctx.storage / ctx.net call throws “permission denied”You didn’t declare storage:local / network:<domain>Add the permission to plugin.json, reinstall (you’ll re-approve)
Your own write doesn’t show in a later querydescribe→apply: reads are from the start-of-turn snapshotRead everything first, then write
onOp never firesMissing read-op-log permission or op-hook capabilityDeclare both in plugin.json

When in doubt, sprinkle ctx.log.info(...) through your handler and watch the plugin log.

Next steps

You’ve now built, installed, and run a plugin end to end. Where to go from here:

  • Plugin API — the full authoring reference: every host method, the manifest in detail, definePlugin, versioning, and the permission model.
  • Plugins — the user side: installing, pinning, updating, approving permissions, and where plugins live on disk.
  • plugin-v1.json — the JSON Schema your plugin.json is validated against.
  • Plugin architecture — how the runtime works under the hood (the Boa JS engine, the describe→apply host loop). The deepest internals live in the outl-plugins crate notes.

A word on distribution

Three ways to share a plugin, from least to most public:

  • A built directory — hand someone the folder (plugin.json + index.js + config.schema.json) and they run outl plugin install <dir>.
  • A GitHub repooutl plugin install github:user/repo clones it at the newest semver tag (or …#v1.2.0 to pin one). No registry entry needed.
  • The official registry — list it in outlmd/registry so it shows up in the in-app marketplace and outl plugin search. See Plugins → Publishing your plugin for the four-step flow.

Still roadmap: .outlpkg single-file packages and outl plugin update.