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:
| Type | Section | Use 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.
-
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 -
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.
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.
-
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 -
The runtime reads the request, builds blocks, prints one response. The request tells you
kind("render"or"action"), thepanelid, the clickedaction(for actions), and the focused pane'scwd:// ~/.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 })); } -
Reload, open the Plugins-adjacent activity bar, and click the 🗒 icon. Clicking Edit notes sends an
actionrequest withid: "edit"; the response'srundirective 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:
| Type | Fields | Renders |
|---|---|---|
section | title | A section heading. |
text | text, dimmed? | A line of text. |
divider | — | A horizontal rule. |
kv | key, value | A key/value row. |
badge | label, color? | A colored pill (red, green, yellow, orange, teal, gray, blue). |
button | id, label, variant? | A button; the click sends an action with this id. |
row | children | Lays 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.
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].
-
Declare a
[webview]. Load a local file withentry(served over the internalguise://origin, so the page can reach the native bridge —file://pages can't) or a remote page withurl— exactly one.placementispanel,window, ortab(tab currently opens as a window). Aserviceentry withbootcan 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" -
The page uses
window.Sinclairto 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> -
The runtime answers
invoke()calls that aren't built-ins. They arrive as amessagerequest; 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 })); } -
Open it from the command palette (Open Board), or bind
open_webview:board. Withplacement = "panel"it also appears as an activity-bar icon.
The bridge (window.Sinclair):
| Call | Does |
|---|---|
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. |
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
| Event | Fires when | when filter |
|---|---|---|
bell | a BEL is received | — |
title_changed | the title changes | substring of the title |
notify | a notification is requested (OSC 9/777/99) | substring of the body |
exit | the pane's process exits | exit-code class |
command_finished | a command finishes (OSC 133 D) | exit-code class |
dir_changed | the 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:
notify = "…"— a desktop notification.run = "…"with optionaltarget—background(default, detached), orpane/tab/split_right/split_down. Runs with the focused pane's cwd.invoke = "method"— calls your[runtime]with the event payload{ event, … }; anyrundirectives it returns are executed. Use this when the reaction needs logic ("was this a test run? did it fail?").
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:
| Method | Args | Result |
|---|---|---|
run_command | text, target? (pane/tab/split_right/split_down) | Runs a command. |
read_screen | lines? | { text } — the visible screen. |
send_input | text | Writes raw bytes to the focused pane. |
new_tab | — | { ok, index }. |
split | direction (right/down) | Splits the focused pane. |
list_panes | — | { panes: [{ title, cwd, focused }] }. |
list_tabs | — | { tabs: [{ index, title, active }], active }. |
focus_tab | index | Focuses a tab. |
list_macros | — | { macros: [{ name, commands }] }. |
run_macro | name | Replays 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
- Write debug output to stderr, not stdout — stdout must be exactly one JSON line. Sinclair discards stderr.
- If a panel shows "Plugin error", your program printed non-JSON, exited non-zero, or timed out (15 s). Check it prints one clean JSON line and returns 0.
- Manifest mistakes surface as diagnostics on stderr at load (run Sinclair from a terminal to see them); the rest of the plugin still loads.
- After editing, reload config (it's watched) or relaunch. Newly installed plugins re-resolve keybindings automatically.
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:
- Has a unique
idthat matches the folder name. - Documents any dependencies (
bun,git,docker, …) in areadme.md. - Bakes in no secrets, tokens, or credentials.
- Ships no destructive or irreversible defaults — nothing that mutates state without asking (no
rm -rf, no force-push). - Keeps requested behavior minimal and predictable; prefer opt-in triggers and cheap event filters.
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.