modisa.Documentation
Plugins

DOCUMENTATION / Automation

Plugins

Programs started with each session: actions, panes, keys, links, and a little of the TUI.

Write one

sh
modisa plugin new my-plugin     # plugin.json, plugin.ts, a test, the client library, AGENTS.md
modisa plugin check my-plugin   # manifest, build, then a throwaway session: starts, connects, passes its tests
modisa plugin link my-plugin    # every session starts it; the running one starts it now

A plugin is a directory with a plugin.json: its name, its protocol version, how to start it (run, an argv run in its directory), and what it offers — actions, panes, keys and links. plugin new writes it in TypeScript with modisa's client library, which handles the protocol, and AGENTS.md, a guide an agent can follow to write the rest.

Nothing needs the library, or TypeScript. A plugin is any program that speaks newline-delimited JSON on the session's socket: Bun, Python, Go, or a shell script around the modisa CLI. It never draws anything itself. It sends text, element trees and panes, and modisa draws them in your theme. The protocol is the same for every language.

A plugin runs as you, with your files and network. It isn't sandboxed.

Install one

sh
modisa plugin install https://github.com/you/modisa-plugins --subdir attention-log --ref v1.2.0
modisa plugin unlink attention-log

install clones the repository (a --ref branch, tag or commit, and a --subdir if the plugin isn't at the top), checks the plugin's directory and manifest, links it and starts it. No build or dependency scripts run before the plugin starts, and it says when the plugin needs setup.

It takes https://, ssh://, git:// and file:// URLs and user@host:path — never a remote-helper (<helper>::) URL or plain http — and git uses only those transports whatever your git config or environment says (GIT_ALLOW_PROTOCOL is set, and variables like GIT_DIR or GIT_CONFIG_COUNT aren't passed on). Your ssh agent, ssh command and credential helpers still apply. It's an installer policy, not a sandbox: the plugin itself runs as you.

Nothing is left behind if it fails; a plugin that installs but won't start says so. plugin list shows each install's source and commit.

Unlinking an install stops it in every running session, then deletes its checkout — never its data or logs. If a session still runs it or can't be reached, the checkout stays and unlink says why. A directory you linked yourself is never deleted. Both commands take --json.

Find one

sh
modisa plugin search            # everything with the modisa-tui-plugin topic, most starred first
modisa plugin search github     # …that also matches these words

It lists public GitHub repositories with the modisa-tui-plugin topic, each with the command that installs it. The same list is on the web, searchable and sortable, in the plugin directory. Nothing in either is vetted: a plugin runs as you, so read it before you install it.

To share one, push it to a public repository with plugin.json at the top and add the modisa-tui-plugin topic. A plugin in a subdirectory is found by its repository, so say which --subdir in the README.

From inside modisa

Ctrl+B P (or Plugins… in the palette, or the settings page's plugins section) opens the plugin manager. Discover searches the plugin index and your marketplaces, and shows exactly where a plugin comes from before it installs; Installed starts, stops, restarts, updates, removes and shows the logs of what you have; Marketplaces adds, updates and removes marketplaces; Add from URL installs from any git URL.

It all happens on the server's machine, so over --remote plugins install where they run. Only you can install: an agent in a pane can't.

Marketplaces

sh
modisa plugin marketplace add acme/modisa-plugins   # or a git URL; --ref to pin one
modisa plugin marketplace list
modisa plugin install attention-log@acme
modisa plugin marketplace update                    # all of them, or one by name
modisa plugin update attention-log                  # an installed plugin, at what its ref is now
modisa plugin marketplace remove acme

A marketplace is a git repository with a modisa-marketplace.json (or .modisa/marketplace.json) at the top: a name, an optional description, and plugins, each a name, a description and a source: a path inside the repository (./plugins/x), a GitHub owner/repo, or { git, ref, subdir }. Its plugins appear in plugin search and in the manager's Discover list. A marketplace is a list someone keeps, not a review: its plugins run as you.

Run it

CommandDoes
plugin list [--json]Every plugin: running or not, connected, its actions, where it came from, and keys that are off.
plugin logs <name>Its stdout and stderr.
plugin stop <name> | start <name>Stop or start it in the session.
plugin run <name> <action> [json]Call one of its actions. A timeout means the outcome is unknown: running it again can repeat its effects.
plugin restart <name>Stop and start it in the session (after a grant, say).
plugin dev <dir> [--watch]A throwaway session with the plugin running, to try it by hand; with --watch it restarts whenever you save.
plugin validate <dir> [--json]Check it without running it: its manifest, permissions, settings, client library and run command.
plugin grant | revoke <name> [permission…]What it may do through modisa (below).

Permissions

A plugin says in its plugin.json what it may do through modisa, and gets only what it asks for and you grant. link and install show what it asks for and, at a terminal, ask you first; plugin grant and plugin revoke change that later (then plugin restart). A request beyond them fails with permission_denied, saying why. A plugin that declares nothing (written before permissions) may do what plugins always could; plugin list says so. No plugin may restart or kill the session.

json
"permissions": ["ui", "panes.read"]
PermissionLets it
uiShow things: status, sidebar, badges, menus, toasts, views, slots that add.
ui.replaceAsk to draw instead of modisa's own widgets ([slots] still decides).
panes.readRead panes' text, and pane.output events.
panes.controlType and run commands in panes; split, close, move, resize, focus and zoom them; open plugin panes.
agentsStart agents.
messagesMessage agents and read the inbox.
notifySystem notifications and sounds.
sessionsCreate, rename and close spaces and tabs.

Permissions are about what a plugin does through modisa: it's still a program running as you. TOOLING.md has the details.

Its settings

A plugin can offer settings — booleans, numbers, choices, text — that show in modisa's settings page under Plugins. The plugin reads them with await modisa.settings() and hears when you change one with modisa.onSettings(fn); they're kept in its $MODISA_PLUGIN_CONFIG/settings.json.

json
"settings": [
  { "key": "threshold", "type": "number", "default": 80, "min": 0, "max": 100, "title": "Warn above (%)" },
  { "key": "provider", "type": "enum", "options": ["claude", "codex", "both"], "default": "both", "title": "Show" }
]

For tests, the client library's renderView(tree, { width, height }) draws a view as text the way modisa does (modisa view render), so a view can be snapshot-tested without a session.

In the TUI

  • Pieces in every part of modisa's own screen (slots, below): the status row, tabs, spaces, pane borders, agents' rows, sidebar sections, the pane, tab and space menus and the palette; and toasts, with rich text and buttons.
  • [sidebar] agents = "radar" in config.toml (or [slots] "sidebar.agents" = "radar") puts that plugin's section where modisa's agent list is, with all its room; when the plugin isn't running or shows nothing, modisa's list is back.
  • Panes the plugin can open: a split, tab or zoomed pane that stays after the plugin stops, an overlay over the focused pane that gives focus back when it closes, or a popup over everything in the client that asked (Ctrl+B x closes it).
  • Its actions are in the command palette. An action taken on a pane gets that pane, already checked to be the same process.
  • Modisa draws everything in your theme and attributes every piece to its plugin, so nothing a plugin shows can pass for modisa's own. Each plugin can only show so much and update so often. It's all cleared when the plugin stops.

Slots

A plugin puts text in your theme (the same spans and lines as views), or in the sidebar a whole element, before or after what modisa draws in a part of its screen, or instead of it. Adding is always allowed; replacing is yours to give: the first plugin (by name) that asks gets it unless [slots] names who may, or "builtin" for nobody. modisa plugin list shows who holds what and who asked. CHROME.md has every slot.

toml
[slots]
"agent.row" = "radar"      # radar's agent rows replace modisa's
"pane.title" = "builtin"   # nobody replaces pane titles
ts
await modisa.ui.slot({ slot: "agent.row", id: "ctx", pane: p.id, instance: p.instance, line: span("43%", "$warn") });
await modisa.ui.slot({ slot: "status.right", id: "usage", line: [span("5h ", "$dim"), span("▂▃▅▇ 72%", "bold $warn")], action: "details" });
await modisa.ui.slot({ slot: "sidebar", id: "chart", title: "Tokens", element: Sparkline({ data }), height: 3 });
SlotsAre
status.left, status.right; status.agents, status.panes, status.git, status.themethe status row; modisa's own segments in it, which a plugin can wrap or replace
tab, spacea tab's label and a space's chip in the tab bar
pane.title, pane.top_right, pane.bottom_left, pane.bottom_righta pane's border: its title, and the corners where modisa draws nothing
agent.row, sidebar, sidebar.agentsan agent's rows in the AGENTS list, a plugin's own sidebar section, and the AGENTS list itself
menu.pane, menu.tab, menu.space, paletteentries in the right-click menus and the command palette

Views

For more than a segment or a row, a plugin opens a view: an element tree modisa draws in your theme, framed and named for the plugin, over everything or over one pane. The elements are ratatui's: constraint layouts, blocks around anything, its widgets, and a few modisa draws itself. It's written as JSX or plain function calls, and needs no program of its own. VIEWS.md has every element and field.

tsx
await modisa.ui.view("main", (
  <Layout direction="horizontal" constraints={[Length(30), Fill(1)]}>
    <List id="files" items={files} selected={at} change="file" block={{ title: "files", border_type: "rounded" }} />
    <Layout constraints={[Fill(1), Length(3)]}>
      <Diff id="diff" cursor action="line" block={{ title: "src/auth.ts" }}>{patch}</Diff>
      <Input id="note" placeholder="comment…" action="comment" block={{ border_type: "rounded" }} />
    </Layout>
  </Layout>
), { title: "Review", keys: [{ key: "s", action: "send", description: "send" }], close: "cancel" });
ElementsAre
Layout, Blockchildren in a direction, sized by constraints (lengths, percentages, ratios, minimums, maximums, fills); a frame with borders, titles, padding and a shadow, around any element
Text, Markdown, Code, Diff, BigTextstyled spans in lines (or a program's coloured output), Markdown, highlighted code and diffs (unified or split, with a line cursor), large type
List, Table, Tabs, Treeselections the user moves through, running the plugin's actions on Enter and as the selection moves
Gauge, LineGauge, Sparkline, BarChart, Chart, Canvas, Calendarratatui's charts, drawn at whatever size the layout gives them
Image, Rastera PNG, JPEG or GIF (in your terminal's graphics protocol where it has one); cells the plugin paints itself, repainted in place up to 60 times a second
Input, Textarea, Button, Spinner, Fill, Clearcontrols that run the plugin's actions with what they hold, and the rest
  • Tab moves between interactive elements, the view's own keys run its actions, Esc closes it (and tells the plugin), and so does Ctrl+B x.
  • Updating a view keeps what you've typed, chosen, opened and scrolled to.
  • Colours are your theme's ($accent, $warn, $dim, …), so a view follows it; text is cleaned like everything else a plugin shows, and a view can only be so big.

Keys

toml
[plugin_keys]
"attention-log.log" = "Y"   # <plugin>.<action or pane>; "" turns it off

A plugin's keys bind keys under the prefix to its actions and panes. A key modisa uses, one reserved for getting out of plugin panes (x, d, Escape), or one two plugins want is off, and the keyboard guide says why. [plugin_keys] moves a key. Each client binds keys with its own config, so two people attached to one session can differ.

With --remote

Plugins run where the server runs, with that machine's files: with --remote, the remote machine. Your client draws what they show, in your theme, with your [plugin_keys] and your notification settings. While the connection is down nothing a plugin showed can be clicked; reconnecting brings it back. A client too old for plugin UI simply doesn't show it.

A plugin is not a pane

MODISA_PANE_ID is deliberately unset, so commands that default to "the calling pane" have no default — always pass a target. And you act with the user's authority: the permission prompts that gate an agent typing into or closing a pane it didn't create do not apply to you.

Events

Call events.subscribe once, then read notifications. Every event carries type and at.

typeExtra fields
pane.createdpane, name, command
process.exitedpane, name, exitCode
agent.statepane, name, harness, from, to
message.sentid, from, to, hops
message.deliveredid, from, to
client.attached—
pane.outputpane, text — opt in with { output: true }; it is every byte of every pane

A plugin that does something at startup or on an event is a long-lived program, not a hook in its manifest: the client library's subscribe gives it every pane as of that moment, then each later event once, in order. Events are not replayed across a disconnect or a server restart: whatever came and went meanwhile is a gap.

Without a manifest

toml
[[plugin]]
run = "bun ~/code/watcher/plugin.ts"

A [[plugin]] line in config.toml starts any program with the session and gives it MODISA_SOCKET, which is the whole API. run goes through a login shell, so your profile can rewrite PATH: use absolute paths. Or skip the socket client — the CLI reads the same variable:

sh
#!/bin/sh
modisa events --follow | while read -r line; do
  echo "$line" | grep -q '"type":"agent.state".*"to":"blocked"' && say "an agent needs you"
done

A worked example

examples/plugins/attention-log is a complete plugin built with plugin new: it logs agents that newly become blocked, shows who's blocked in the status row, the sidebar and on their panes, and opens the log in a popup. examples/plugins/worktrees gives each branch a git worktree and its own space (modisa plugin run worktrees create '{"branch":"feat-x"}'). examples/plugins/ is the full guide to the protocol underneath.

Commands, keys, agents, settings…