Extending
Plugins
A Sinclair plugin is just a folder with a plugin.toml inside. There are three kinds: declarative command plugins that contribute shell commands you can run in a pane, tab, or split; live IPC panels that paint a side-drawer UI from a JSON block tree; and webview plugins that host their own HTML/JS surface in a panel, window, or tab. Any of them can also add event triggers that react to terminal events. All are plain folders — no build step, nothing to compile.
This page is the reference. For a hands-on, build-it-yourself walkthrough of all four types — plus the protocol, host capabilities, debugging, and publishing — start with the Plugin development tutorial.
Where plugins live
On launch, Sinclair scans a per-user plugins directory. Each plugin is its own subfolder containing a single plugin.toml:
~/.config/sinclair/plugins/<name>/plugin.toml
# or, when XDG_CONFIG_HOME is set:
$XDG_CONFIG_HOME/sinclair/plugins/<name>/plugin.toml
You can also point at a plugin folder anywhere on disk from your settings file (~/.config/sinclair/settings.json) with a plugin entry. The key is an array, so add as many as you like:
// ~/.config/sinclair/settings.json
{
"plugin": ["/path/to/your/plugin-dir"]
}
Plugins load on the next launch. Config reloads live, so most config saves pick up a newly added plugin line without a full restart.
Installing from the catalog
The catalog is this repository's plugins/ folder, browsable directly inside Sinclair. Open the Plugins panel from the activity bar (the icon), browse the Available list, and click Install. Sinclair downloads the plugin into ~/.config/sinclair/plugins/ and loads it.
To install manually, copy or symlink a catalog folder into your plugins directory:
cp -R plugins/git ~/.config/sinclair/plugins/git
# or, to track the catalog as it updates:
ln -s "$PWD/plugins/git" ~/.config/sinclair/plugins/git
Use $XDG_CONFIG_HOME/sinclair/plugins/ instead of ~/.config/sinclair/plugins/ if you have XDG_CONFIG_HOME set.
Command plugins
The simplest kind of plugin contributes one or more shell commands. The manifest is a small TOML subset: a few root keys describing the plugin, then one or more repeated [[command]] tables.
Root keys
| Key | Required | Default | Notes |
|---|---|---|---|
id | Yes | — | Lowercase ASCII letters, digits, ., and - only. Must match the folder name. |
name | No | the id | Human-friendly display name. |
version | No | "0.0.0" | Free-form version string. |
description | No | — | One-line summary of what the plugin adds. |
[[command]] fields
| Field | Required | Default | Notes |
|---|---|---|---|
id | Yes | — | Same charset rule as the plugin id; unique within the plugin. |
title | No | the command id | Label shown in the command palette and menus. |
run | Yes | — | The shell command text to execute. |
mode | No | pane | One of pane, tab, split-right, split-down. |
keybind | No | — | A default keybinding, e.g. cmd+ctrl+g. |
Command modes
| Mode | Behavior |
|---|---|
pane (default) | Types the command into the focused shell and presses enter. |
tab | Opens a new tab and runs the command there. |
split-right | Opens a split to the right and runs the command there. |
split-down | Opens a split below and runs the command there. |
A complete example
For example, a one-command manifest that launches the lazygit TUI in a new tab:
# plugins/lazygit/plugin.toml
id = "lazygit"
name = "LazyGit"
version = "0.1.0"
description = "Launch the lazygit terminal UI in a new tab"
[[command]]
id = "open" # required
title = "Open LazyGit" # shown in the command palette
run = "lazygit" # required: the shell command
mode = "tab" # pane | tab | split-right | split-down
keybind = "cmd+ctrl+g" # optional default binding
How commands become actions
Every contributed command becomes a stable action named after the plugin and command ids:
plugin_command:<plugin-id>/<command-id>
# for the example above:
plugin_command:lazygit/open
The action is bindable and unbindable from your own config, shows up in the command palette, and is reachable from quick-open. The keybind in plugin.toml is only a default; a binding in your config always wins:
// ~/.config/sinclair/settings.json
{
"keybind": ["cmd+ctrl+g=plugin_command:lazygit/open"]
}
To remove a default binding entirely, rebind the key elsewhere or point it at unbind:
{
"keybind": ["cmd+ctrl+g=unbind"]
}
A plugin keybind overrides any built-in binding with the same trigger. Avoid Sinclair's ⌘⇧ defaults (Compose, Broadcast, the relay shortcuts, and so on) — use the ⌘⌃ namespace for plugin commands to stay clear of them.
IPC plugins (live panels)
Beyond the declarative command model, a plugin can contribute a live side-drawer panel rendered from a block tree. Add two tables to plugin.toml: a [runtime] that names the program to spawn (any language) and a [panel] that describes the activity-bar entry.
# plugins/git/plugin.toml
id = "git"
name = "Git"
version = "0.1.0"
description = "A live git panel: branch, changes, and one-click actions"
[runtime]
command = "bun run plugin.ts" # any language; reads stdin, writes stdout
[panel]
id = "git"
title = "Git"
icon = "⎇" # activity-bar glyph
Sinclair invokes the runtime once per event, serverless-style: it passes a JSON request on the program's stdin and reads a single JSON response from stdout. Each render or action spawns a fresh, short-lived process, run off the UI thread — there is no long-running server to manage and nothing to keep warm.
The protocol
The runtime receives one request and prints one response.
Request — what Sinclair sends on stdin:
{
"kind": "render" | "action", # why the runtime was invoked
"panel": "git", # the panel id from [panel]
"action": "stage_all", # present only when kind == "action"
"cwd": "/path/to/repo" # the focused pane's working dir
}
Response — what the runtime prints on stdout:
{
"title": "Git · main", # optional panel title
"blocks": [ ... ], # the UI as a block tree
"run": [ # optional: commands to execute
{ "text": "git log --oneline --graph -20", "target": "pane" }
]
}
A render request asks the runtime to paint the panel. When a button is clicked, Sinclair sends an action request carrying that button's id — the runtime acts on it and returns a fresh block tree to redraw. Any run directives in a response are executed in the focused terminal; each directive's target chooses where: pane (default), tab, split_right, or split_down.
Block types
The blocks array describes the panel UI. Each block is an object with a type:
| Type | Fields | Renders |
|---|---|---|
section | title | A section heading. |
text | text, dimmed? | A line of text; dimmed: true renders it muted. |
divider | — | A horizontal rule. |
kv | key, value | A key/value row. |
badge | label, color? | A small colored pill, e.g. status codes. |
button | id, label, variant? | A clickable button; the click fires an action with this id. |
row | children | Lays out child blocks horizontally. |
Button variant is one of filled, light, outline, subtle, default, transparent, or white.
Webview plugins
When a block tree isn't enough, a plugin can host a web view — a native OS web view (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux) running arbitrary HTML/JS. Add a [webview] table:
[webview]
id = "dashboard"
title = "Dashboard"
icon = "◱" # activity-bar / tab glyph
placement = "panel" # panel | window | tab
entry = "index.html" # a file in the plugin dir (file://) …
# url = "https://…" # … or a URL instead of entry (exactly one)
# boot = true # with a [runtime]: invoke boot for the URL (see below)
Open it from the command palette (Open <title>), the right sidebar (for placement = "panel"), the Plugins menu, or the open_webview:<id> action. All three placements are real — tab opens a proper editor-style tab.
boot — serve your own app. With boot = true and a [runtime], the app invokes the runtime's boot method (from Rust, not the JS bridge) before loading, and navigates to the address it returns — { "url": … }, or { "port": N } substituted into the manifest url's {port} placeholder (url = "http://127.0.0.1:{port}/"). Use it to start a local server and load from a real http origin. Sinclair's built-in Notes surface (File → Notes) uses exactly this pattern, backed by a bundled Rust vault server.
An entry page is served from the plugin directory over an internal guise:// origin — a real origin, so the window.Sinclair bridge, ES modules, and fetch all work. (A page loaded directly over file:// — e.g. a literal url = "file://…" — would break the bridge, since messages from a file:// frame are silently dropped; use entry or a served url.)
The window.Sinclair bridge
Sinclair injects a small JavaScript bridge into the page so it can drive the terminal and call back to the plugin:
| Call | Does |
|---|---|
Sinclair.runCommand(text, target?) | Runs a command in the focused terminal. |
Sinclair.readScreen(lines?) | Reads the visible screen; resolves { text }. |
Sinclair.invoke(method, params?) | Returns a Promise. Built-in methods (the same capabilities as run directives) are handled by the app; anything else is forwarded to the plugin's [runtime]. |
Sinclair.postMessage(data) | Fire-and-forget message to the runtime. |
Sinclair.onMessage(cb) | Receive pushes from the host. |
An invoke of a non-built-in method reaches the runtime as a message request — { "kind": "message", "panel", "method", "params"?, "cwd"? } — and the runtime's { "result": … } resolves the page's promise. The dashboard example ships an HTML page plus a runtime that answers a ping.
Trigger plugins
A plugin doesn't need any UI at all — it can just react to events. Add one or more [[trigger]] tables, each hooking an event and running one action:
[[trigger]]
on = "command_finished" # the event to hook
when = "nonzero" # optional filter
notify = "A command failed" # one action: notify | run | invoke
| 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 |
worktree_created | a git worktree is created | substring of the path |
worktree_removed | a git worktree is removed | substring of the path |
command_finished and dir_changed need shell integration enabled. The exit-code filter is any, zero/success, or nonzero/failure.
Each trigger runs exactly one action: notify = "…" (a desktop notification); run = "…" with an optional target (background — the default, detached — or pane/tab/split_right/split_down, run with the focused pane's cwd); or invoke = "method", which calls the plugin's [runtime] with the event payload and runs any run directives it returns. See alert for a complete example.
Agent tools
The bridge lets a plugin call into the terminal. [[tool]] is the other direction: a plugin exposes a tool your AI agents can call. Every plugin tool joins the built-in terminal tools in Sinclair's MCP server, so any MCP client — Claude, or an agent running in a relay session — sees and can invoke it. This is what makes Sinclair's plugins agent-native: a plugin isn't just UI for you, it's a capability for your agents.
Add one or more [[tool]] tables. A tool needs a [runtime] to handle it; parameters are declared as param = "name | type | description | required" (type defaults to string; the host assembles them 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"
The tool is exposed to agents as <plugin-id>_<tool-id> (e.g. sysinfo_stats). When an agent calls it, the app invokes your [runtime] with a tool request — kind: "tool", method: the tool id, params: the arguments — and returns whatever you put in result:
// plugin.ts — handling a tool call
const req = JSON.parse((await Bun.stdin.text()) || "{}");
if (req.kind === "tool" && req.method === "stats") {
const at = (req.params && req.params.cwd) || process.cwd();
console.log(JSON.stringify({ result: { host: "…", load: "…", disk: {} } }));
}
Tool handlers run wherever sinclair mcp runs, so they don't need the GUI — a plugin tool is just a spawn of your runtime with the agent's arguments. The bundled sysinfo plugin ships a working sysinfo_stats tool.
Capabilities
A plugin can declare what it accesses with repeated capability keys, from a known set: commands, screen, network, filesystem, clipboard, notify. The declarations are shown in the Plugin Manager (an "accesses: network, filesystem" line) so users can see a plugin's reach before trusting it.
id = "backups"
description = "Snapshot the current directory to S3."
capability = "commands"
capability = "network"
capability = "filesystem"
Today a process [runtime] runs with your full user privileges, so capabilities are advisory — a declaration of intent, not a sandbox. They are the vocabulary the upcoming WebAssembly plugin runtime will enforce: a WASM plugin will only receive the capabilities it declares. Declare them now so your plugin is ready.
WebAssembly runtime in progress
A plugin can declare a WebAssembly runtime instead of a subprocess — the direction that makes plugins dependency-free (no bun/node), sandboxed to their declared capabilities, and safe to distribute:
[runtime]
type = "wasm" # vs the default "process"
wasm = "plugin.wasm" # module path, relative to the plugin
capability = "screen" # the host functions the module may import
The manifest surface and capability model ship today and validate; the execution engine is the next build. Declaring a wasm runtime now lets plugins and the host adopt it incrementally — until the engine lands, invoking one returns a clear "not yet executable" message. The full design (the WIT host world, capability-gated imports, the guest toolchain, and the migration path) is in docs/plugins-wasm.md.
Write your first plugin
Build a tiny IPC panel that shows the current directory and a refresh button.
-
Make the folder:
mkdir -p ~/.config/sinclair/plugins/hello cd ~/.config/sinclair/plugins/hello -
Write
plugin.tomlwith a[runtime]and a[panel]:# ~/.config/sinclair/plugins/hello/plugin.toml id = "hello" name = "Hello" [runtime] command = "bun run plugin.ts" [panel] id = "hello" title = "Hello" icon = "👋" -
Write
plugin.ts— read the request from stdin, build a block tree, print one JSON response:// ~/.config/sinclair/plugins/hello/plugin.ts type Block = | { type: "section"; title: string } | { 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 blocks: Block[] = [ { type: "section", title: "Hello" }, { type: "kv", key: "cwd", value: cwd }, { type: "button", id: "refresh", label: "Refresh", variant: "filled" }, ]; console.log(JSON.stringify({ title: "Hello", blocks })); -
The folder already sits in
~/.config/sinclair/plugins/, so relaunch Sinclair, open the Plugins activity bar, and click the panel. Clicking Refresh re-invokesplugin.tswith anactionrequest — branch onreq.kindto handle it.
The runtime can be any program, not just Bun — anything that reads a JSON line on stdin and prints a JSON line on stdout. See plugins/git/plugin.ts in the repo for a complete example that reads real git state and wires up stage, fetch, and a run directive. For the full step-by-step build of all four plugin types, see the Plugin development tutorial.
Example plugins
Every plugin in the catalog is "involved" — a web view, a live panel, or an event hook. Web views and a trigger:
| Plugin | What it does | Requires |
|---|---|---|
dashboard | A webview panel that runs commands and calls its own runtime. | bun |
alert | A trigger that desktop-notifies when a command exits non-zero. | — |
Live IPC panels:
| Plugin | What it does | Requires |
|---|---|---|
git | Live branch / changes panel with stage, fetch, and log actions. | bun, git |
sysinfo | Host load and disk panel with a monitor shortcut. | bun |
docker | Running-containers panel with stats and prune actions. | bun, docker |
promptdesigner | Design your shell prompt and apply it to your shell. See the Prompt Designer page. | bun |
The declarative command model is still fully supported (see above) — it's just not something worth shipping as a standalone catalog example.
Publishing to the catalog
Sinclair keeps a community catalog at the root of the repository. To share your plugin, open a pull request against github.com/wess/sinclair that adds your folder under plugins/:
plugins/<your-plugin-name>/plugin.toml
plugins/<your-plugin-name>/readme.md
Include a short readme.md describing what the plugin does, any dependencies, an install hint, and the keybind(s). Before you open the PR, run through the checklist:
- Plugin
idis unique and matches the folder name. - Each command has a sensible
title. - Dependencies are documented in the
readme.md. - No secrets, tokens, or credentials baked into
run. - No destructive or irreversible defaults (no
rm -rf, no force-push, nothing that mutates state without asking).