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.

New to plugins?

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
Note

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

KeyRequiredDefaultNotes
idYesLowercase ASCII letters, digits, ., and - only. Must match the folder name.
nameNothe idHuman-friendly display name.
versionNo"0.0.0"Free-form version string.
descriptionNoOne-line summary of what the plugin adds.

[[command]] fields

FieldRequiredDefaultNotes
idYesSame charset rule as the plugin id; unique within the plugin.
titleNothe command idLabel shown in the command palette and menus.
runYesThe shell command text to execute.
modeNopaneOne of pane, tab, split-right, split-down.
keybindNoA default keybinding, e.g. cmd+ctrl+g.

Command modes

ModeBehavior
pane (default)Types the command into the focused shell and presses enter.
tabOpens a new tab and runs the command there.
split-rightOpens a split to the right and runs the command there.
split-downOpens 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"]
}
Warning

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:

TypeFieldsRenders
sectiontitleA section heading.
texttext, dimmed?A line of text; dimmed: true renders it muted.
dividerA horizontal rule.
kvkey, valueA key/value row.
badgelabel, color?A small colored pill, e.g. status codes.
buttonid, label, variant?A clickable button; the click fires an action with this id.
rowchildrenLays 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.

entry is served, not file://

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:

CallDoes
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
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
worktree_createda git worktree is createdsubstring of the path
worktree_removeda git worktree is removedsubstring 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: {} } }));
}
Tip

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"
Note

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.

  1. Make the folder:

    mkdir -p ~/.config/sinclair/plugins/hello
    cd ~/.config/sinclair/plugins/hello
  2. Write plugin.toml with 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 = "👋"
  3. 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 }));
  4. The folder already sits in ~/.config/sinclair/plugins/, so relaunch Sinclair, open the Plugins activity bar, and click the panel. Clicking Refresh re-invokes plugin.ts with an action request — branch on req.kind to handle it.

Tip

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:

PluginWhat it doesRequires
dashboardA webview panel that runs commands and calls its own runtime.bun
alertA trigger that desktop-notifies when a command exits non-zero.

Live IPC panels:

PluginWhat it doesRequires
gitLive branch / changes panel with stage, fetch, and log actions.bun, git
sysinfoHost load and disk panel with a monitor shortcut.bun
dockerRunning-containers panel with stats and prune actions.bun, docker
promptdesignerDesign 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: