DOCUMENTATION / Automation
Plugins
Programs started with each session: actions, panes, keys, links, and a little of the TUI.
Write one
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 nowA 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
modisa plugin install https://github.com/you/modisa-plugins --subdir attention-log --ref v1.2.0
modisa plugin unlink attention-loginstall 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
modisa plugin search # everything with the modisa-tui-plugin topic, most starred first
modisa plugin search github # …that also matches these wordsIt 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
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 acmeA 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
| Command | Does |
|---|---|
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.
"permissions": ["ui", "panes.read"]| Permission | Lets it |
|---|---|
ui | Show things: status, sidebar, badges, menus, toasts, views, slots that add. |
ui.replace | Ask to draw instead of modisa's own widgets ([slots] still decides). |
panes.read | Read panes' text, and pane.output events. |
panes.control | Type and run commands in panes; split, close, move, resize, focus and zoom them; open plugin panes. |
agents | Start agents. |
messages | Message agents and read the inbox. |
notify | System notifications and sounds. |
sessions | Create, 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.
"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,taborzoomedpane that stays after the plugin stops, anoverlayover the focused pane that gives focus back when it closes, or apopupover 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.
[slots]
"agent.row" = "radar" # radar's agent rows replace modisa's
"pane.title" = "builtin" # nobody replaces pane titlesawait 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 });| Slots | Are |
|---|---|
status.left, status.right; status.agents, status.panes, status.git, status.theme | the status row; modisa's own segments in it, which a plugin can wrap or replace |
tab, space | a tab's label and a space's chip in the tab bar |
pane.title, pane.top_right, pane.bottom_left, pane.bottom_right | a pane's border: its title, and the corners where modisa draws nothing |
agent.row, sidebar, sidebar.agents | an agent's rows in the AGENTS list, a plugin's own sidebar section, and the AGENTS list itself |
menu.pane, menu.tab, menu.space, palette | entries 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.
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" });| Elements | Are |
|---|---|
Layout, Block | children 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, BigText | styled 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, Tree | selections the user moves through, running the plugin's actions on Enter and as the selection moves |
Gauge, LineGauge, Sparkline, BarChart, Chart, Canvas, Calendar | ratatui's charts, drawn at whatever size the layout gives them |
Image, Raster | a 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, Clear | controls 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
[plugin_keys]
"attention-log.log" = "Y" # <plugin>.<action or pane>; "" turns it offA 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.
Links
"links": [
{ "pattern": "https://github.com/*/pull/*", "action": "open-pr" },
{ "regex": "^https://github\\.com/[^/]+/[^/]+/pull/\\d+$", "action": "open-pr" }
]- Ctrl+click an http or https URL in a pane to hand it to the action whose link matches. When several plugins match, you choose.
patternis a URL glob:*matches anything, the rest is literal, and the scheme and host ignore case.regexis RE2 syntax, matched in linear time, so there are no backreferences or lookaround. It sees the URL exactly as shown; anchor it with^and$, and use(?i)to ignore case.- The action gets the URL as data, exactly as clicked. Terminal hyperlinks (OSC 8) whose label differs from their destination aren't followed.
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.
| type | Extra fields |
|---|---|
pane.created | pane, name, command |
process.exited | pane, name, exitCode |
agent.state | pane, name, harness, from, to |
message.sent | id, from, to, hops |
message.delivered | id, from, to |
client.attached | — |
pane.output | pane, 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
[[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:
#!/bin/sh
modisa events --follow | while read -r line; do
echo "$line" | grep -q '"type":"agent.state".*"to":"blocked"' && say "an agent needs you"
doneA 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.