Extending

Plugin development tutorial

A hands-on walk from an empty folder to four working plugins — a command launcher, a live side panel, an interactive web view, and an event-driven automation — then the full protocol, the host capabilities your plugin can call, and how to debug and publish. Nothing here needs a build step or links against the app; a plugin is a folder with a plugin.toml and, optionally, a program that speaks JSON over stdio. For the terse reference, see the Plugins page.

What a plugin can be

A single plugin folder can mix any of five contribution types. Pick the smallest that does the job:

TypeSectionUse it for
Command[[command]]Run a shell command in a pane, tab, or split (optionally bound to a key).
Panel[runtime] + [panel]A live side-drawer UI painted from a JSON block tree, with buttons.
Webview[webview]Your own HTML/JS surface in a panel, window, or tab, wired to the terminal.
Trigger[[trigger]]React to terminal events (command finished, dir changed, bell…) with no UI.
Tool[runtime] + [[tool]]A capability your AI agents can call — exposed through Sinclair's MCP server.

Set up a development loop

Sinclair loads plugins from $XDG_CONFIG_HOME/sinclair/plugins (usually ~/.config/sinclair/plugins/) and from any folder you add to your config with a plugin line. Config is watched live, so while you iterate it is easiest to point Sinclair at a working directory:

// ~/.config/sinclair/settings.json
{
  "plugin": ["/Users/you/code/my-plugin"]
}

Each plugin is a folder containing plugin.toml. There is no build step: edit the manifest or the runtime script, and reload. Command and panel contributions pick up on a config reload; if a change doesn't show, relaunch Sinclair. A conventional layout:

my-plugin/
  plugin.toml     # the manifest — always required
  plugin.ts       # optional runtime (any language)
  index.html      # optional webview page
  readme.md       # document dependencies + what it does

The manifest root

Every plugin.toml starts with root keys. Only id is required; it must be lowercase letters, numbers, . or -, and should match the folder name.

id = "example"           # required, unique
name = "Example"         # display name (defaults to id)
version = "0.1.0"
description = "One line about what this does"

A malformed line never aborts the load — Sinclair reports a friendly diagnostic and keeps every valid contribution. That means you can iterate without a half-broken manifest taking the whole plugin down.

Tutorial 1 — a command plugin

The simplest plugin runs a shell command. Here's one that opens lazygit in a new tab and binds it to a key.

  1. Create the folder and manifest:

    # ~/.config/sinclair/plugins/lazy/plugin.toml
    id = "lazy"
    name = "Lazygit"
    description = "Open the lazygit TUI in a tab"
    
    [[command]]
    id = "open"
    title = "Open lazygit"
    run = "lazygit"
    mode = "tab"              # pane | tab | split-right | split-down
    keybind = "cmd+ctrl+g"    # optional default binding — pick one Sinclair doesn't use
  2. Reload. The command becomes the action plugin_command:lazy/open — it shows up in the command palette (⌘⇧P) as Lazygit: Open lazygit, and ⌘⇧G runs it. A user can rebind or unbind it in their config like any built-in action.

The four modes map to where the command lands: pane types it into the focused shell, tab opens a new tab, and split-right/split-down open a split. Add as many [[command]] tables as you like — a "scripts" plugin might read package.json and emit one command per script.

Tip

Command plugins are fully declarative — no runtime, no scripting. If all you need is "launch X here," stop here.

Tutorial 2 — a live panel plugin

A panel plugin paints a side-drawer UI from a block tree. Sinclair runs your [runtime] program once per event (serverless-style): it sends one JSON request on stdin, you print one JSON response on stdout, and the process exits. No long-running server, no state to manage.

We'll build a "notes" panel: it shows the notes saved for the current directory and has a button to open them in an editor.

  1. The manifest declares a [runtime] (the program to spawn) and a [panel] (the activity-bar entry):

    # ~/.config/sinclair/plugins/notes/plugin.toml
    id = "notes"
    name = "Notes"
    
    [runtime]
    command = "bun run plugin.ts"   # any program: reads stdin, writes stdout
    
    [panel]
    id = "notes"
    title = "Notes"
    icon = "🗒"                       # single-glyph activity-bar icon
  2. The runtime reads the request, builds blocks, prints one response. The request tells you kind ("render" or "action"), the panel id, the clicked action (for actions), and the focused pane's cwd:

    // ~/.config/sinclair/plugins/notes/plugin.ts
    type Block =
      | { type: "section"; title: string }
      | { type: "text"; text: string; dimmed?: boolean }
      | { type: "divider" }
      | { type: "kv"; key: string; value: string }
      | { type: "button"; id: string; label: string; variant?: string };
    
    const req = JSON.parse((await Bun.stdin.text()) || "{}");
    const cwd: string = req.cwd || process.cwd();
    const file = `${cwd}/.notes.md`;
    
    // A button click arrives as an action; open the notes file in $EDITOR.
    if (req.kind === "action" && req.action === "edit") {
      console.log(JSON.stringify({
        run: [{ text: `\${EDITOR:-vi} ${file}`, target: "split_right" }],
      }));
    } else {
      let body = "";
      try { body = await Bun.file(file).text(); } catch {}
      const blocks: Block[] = [
        { type: "section", title: "Notes" },
        { type: "kv", key: "file", value: ".notes.md" },
        { type: "divider" },
        { type: "text", text: body.trim() || "No notes yet.", dimmed: !body.trim() },
        { type: "button", id: "edit", label: "Edit notes", variant: "filled" },
      ];
      console.log(JSON.stringify({ title: "Notes", blocks }));
    }
  3. Reload, open the Plugins-adjacent activity bar, and click the 🗒 icon. Clicking Edit notes sends an action request with id: "edit"; the response's run directive opens the file in a split. After an action the panel re-renders, so the new note shows up.

Blocks are the vocabulary for the panel UI. The full set:

TypeFieldsRenders
sectiontitleA section heading.
texttext, dimmed?A line of text.
dividerA horizontal rule.
kvkey, valueA key/value row.
badgelabel, color?A colored pill (red, green, yellow, orange, teal, gray, blue).
buttonid, label, variant?A button; the click sends an action with this id.
rowchildrenLays child blocks out horizontally.

A response may also carry run directives — commands Sinclair runs in the terminal after rendering. Each is { "text", "target"? }, where target is pane (default), tab, split_right, or split_down. That's how a panel button ends up doing something in the shell.

Note

The runtime spawns fresh for every render and every click, and is killed if it runs longer than 15 seconds. Keep it fast, read what you need from cwd, and don't expect in-memory state to survive between calls — persist to disk if you need it.

Tutorial 3 — a webview plugin

When a block tree isn't enough, host your own HTML/JS in a native web view. The page talks to Sinclair through an injected window.Sinclair bridge; unknown calls are forwarded to your [runtime].

  1. Declare a [webview]. Load a local file with entry (served over the internal guise:// origin, so the page can reach the native bridge — file:// pages can't) or a remote page with url — exactly one. placement is panel, window, or tab (tab currently opens as a window). A service entry with boot can even have Sinclair run a local server for you on an auto-allocated port.

    # ~/.config/sinclair/plugins/board/plugin.toml
    id = "board"
    name = "Board"
    
    [runtime]
    command = "bun run plugin.ts"   # answers invoke() calls
    
    [webview]
    id = "board"
    title = "Board"
    icon = "◱"
    placement = "window"            # panel | window | tab
    entry = "index.html"
  2. The page uses window.Sinclair to drive the terminal and call the runtime:

    <!-- ~/.config/sinclair/plugins/board/index.html -->
    <button onclick="run()">Run in terminal</button>
    <button onclick="ping()">Ask the runtime</button>
    <pre id="out"></pre>
    <script>
      const out = document.getElementById("out");
      async function run() {
        const r = await Sinclair.runCommand("echo hello from the web view");
        out.textContent = JSON.stringify(r);
      }
      async function ping() {
        out.textContent = JSON.stringify(await Sinclair.invoke("ping", { at: Date.now() }));
      }
    </script>
  3. The runtime answers invoke() calls that aren't built-ins. They arrive as a message request; reply with { "result": … }, which resolves the page's promise:

    // ~/.config/sinclair/plugins/board/plugin.ts
    const req = JSON.parse((await Bun.stdin.text()) || "{}");
    if (req.kind === "message" && req.method === "ping") {
      console.log(JSON.stringify({ result: { pong: true, at: req.params?.at } }));
    } else {
      console.log(JSON.stringify({ result: null }));
    }
  4. Open it from the command palette (Open Board), or bind open_webview:board. With placement = "panel" it also appears as an activity-bar icon.

The bridge (window.Sinclair):

CallDoes
Sinclair.runCommand(text, target?)Run a command in the focused terminal.
Sinclair.readScreen(lines?)Read the visible screen; resolves { text }.
Sinclair.invoke(method, params?)Promise. Built-in methods run in the app; anything else goes to your [runtime].
Sinclair.postMessage(data)Fire-and-forget message to the runtime.
Sinclair.onMessage(cb)Receive pushes from the host.
Tip

For a self-contained page, keep CSS and JS inline in index.html. If you need multiple assets or a framework build, run your own local server in the [runtime] and point url at http://localhost:PORT instead of using entry.

Tutorial 4 — event triggers

Triggers run an action when a terminal event fires — no UI, no manual command. Add [[trigger]] tables. This plugin notifies you when a command fails and reloads your environment when you change directories:

# ~/.config/sinclair/plugins/watch/plugin.toml
id = "watch"
name = "Watch"

[[trigger]]
on = "command_finished"     # event
when = "nonzero"            # only failures
notify = "A command failed"  # action: desktop notification

[[trigger]]
on = "dir_changed"
run = "direnv reload"
target = "background"        # detached; no terminal UI
EventFires whenwhen filter
bella BEL is received
title_changedthe title changessubstring of the title
notifya notification is requested (OSC 9/777/99)substring of the body
exitthe pane's process exitsexit-code class
command_finisheda command finishes (OSC 133 D)exit-code class
dir_changedthe cwd changes (OSC 7)substring of the path

command_finished and dir_changed depend on shell integration (enable it in Settings). The exit-code filter is any, zero/success, or nonzero/failure. Each trigger runs exactly one action:

Note

command_finished fires after every command (including a success at shell start), so filter early — when = "nonzero" or an invoke that returns quickly. dir_changed only fires when the directory actually changes, not on every prompt.

Tutorial 5 — an agent tool

The previous tutorials extend the terminal for you. A [[tool]] extends it for your AI agents: it joins Sinclair's MCP server, so any MCP client — Claude, or an agent running in a relay session — can call it alongside the built-in terminal tools. This is what makes a plugin a capability, not just a UI.

A tool needs a [runtime] to handle it. Declare it with a description (shown to the agent) and parameters as param = "name | type | description | required" — the host turns these into the MCP inputSchema:

[runtime]
command = "bun run plugin.ts"

[[tool]]
id = "stats"
description = "Read host system stats: hostname, load, and disk usage."
param = "cwd | string | Directory to report on"

Your runtime receives a tool request — kind: "tool", method: the tool id, params: the agent's arguments — and returns its answer in result. The tool is exposed to agents as <plugin-id>_<tool-id> (here, sysinfo_stats):

// plugin.ts
const req = JSON.parse((await Bun.stdin.text()) || "{}");
if (req.kind === "tool" && req.method === "stats") {
  const at = (req.params && req.params.cwd) || process.cwd();
  const disk = /* …run df at `at`… */ {};
  console.log(JSON.stringify({ result: { host: "…", load: "…", disk } }));
}

Now an agent can ask "what's the disk usage here?" and call sysinfo_stats directly — no shell round-trip, no screen-scraping. Tool handlers run wherever sinclair mcp runs, so they don't need the GUI. See the bundled sysinfo plugin for the complete example, and the MCP page for connecting a client.

Host capabilities

Panels (via run directives), webviews (via Sinclair.invoke), and triggers (via invoke) can ask the app to do things. These built-in methods are available:

MethodArgsResult
run_commandtext, target? (pane/tab/split_right/split_down)Runs a command.
read_screenlines?{ text } — the visible screen.
send_inputtextWrites raw bytes to the focused pane.
new_tab{ ok, index }.
splitdirection (right/down)Splits the focused pane.
list_panes{ panes: [{ title, cwd, focused }] }.
list_tabs{ tabs: [{ index, title, active }], active }.
focus_tabindexFocuses a tab.
list_macros{ macros: [{ name, commands }] }.
run_macronameReplays a saved macro.

The protocol, in full

Runtime plugins exchange one JSON object each way over stdio. The request Sinclair sends:

{
  "kind":   "render" | "action" | "message",
  "panel":  "notes",          // the panel or webview id
  "action": "edit",           // present when kind == "action"
  "method": "ping",           // present when kind == "message"
  "params": { },              // arbitrary, for messages
  "cwd":    "/path/to/repo"   // the focused pane's working dir
}

The response you print:

{
  "title":  "Notes",                          // optional: retitle a panel live
  "blocks": [ /* block tree */ ],             // panel UI
  "run":    [ { "text": "…", "target": "pane" } ], // commands to execute
  "result": { }                               // resolves a webview/trigger invoke()
}

Every field is optional — an empty {} is a valid response. Use blocks/title for panels, result for message replies, and run anywhere you want a terminal side effect.

Debugging & testing

Because the runtime is just a program that reads stdin and writes stdout, you can drive it by hand — no app required:

# render request
echo '{"kind":"render","panel":"notes","cwd":"/tmp"}' | bun run plugin.ts

# a button click
echo '{"kind":"action","panel":"notes","action":"edit","cwd":"/tmp"}' | bun run plugin.ts

# a webview invoke
echo '{"kind":"message","panel":"board","method":"ping","params":{"at":1}}' | bun run plugin.ts

Publishing to the catalog

Plugins can be installed from a shared catalog inside Sinclair (the Plugins panel → browse & install). To contribute one, open a PR that adds your folder under plugins/ in the repo. A good submission:

Tip

The repo's plugins/ folder is the best reference: git and docker for panels, dashboard for a webview, alert for triggers, and the command plugins for the declarative model.