Write an extension
This page is for extension authors. It walks through a first extension, then covers the manifest, commands, services, sidebar tabs, and the environment an extension runs in. To install and manage extensions, see Extensions; to test one in isolation, see Extension testing.
An extension is a directory containing an extension.toml manifest and, usually, the programs that manifest launches. Those programs talk to rozi through the same control commands that scripts use.
Create your first extension
Generate a scaffold, validate it, link it, and run its command:
rozi extensions new my-extension
cd my-extension
rozi extensions check .
rozi extensions install --link .
rozi run-action my-extension.hellorozi extensions new prints these steps after it creates the scaffold. --link makes this checkout the installed extension, and installing it reloads running clients automatically. After an edit inside the checkout you run reload-extensions to pick up changes. The last command shows a "Hello from my-extension" notification in the running rozi; run the commands from a pane in it, or open the command palette and choose Hello from my-extension.
rozi extensions new requires Python 3 and creates:
my-extension/
├── extension.toml
├── bin/
│ └── hello.py
└── README.mdThe manifest declares the extension and one command:
[extension]
id = "my-extension"
title = "my-extension"
description = "A Rozi extension"
version = "0.1.0"
api = 1
[[commands]]
id = "hello"
label = "Hello from my-extension"
exec = ["python3", "{extension_dir}/bin/hello.py"]In outline, bin/hello.py calls back into rozi through the environment it receives:
import os
import subprocess
rozi = os.environ.get("ROZI_BIN", "rozi")
extension = os.environ["ROZI_EXTENSION"]
subprocess.run([rozi, "notify", f"Hello from {extension}"], check=False)The scaffold picks the Python launcher available on your machine (python3, python, or py -3 on Windows); adjust exec if you share the extension with a platform whose launcher differs.
Keep mutable files out of the extension directory: only immutable package data belongs beside the manifest. Write anything the extension keeps to its state directory.
Extension metadata
Every manifest starts with an [extension] table:
[extension]
id = "my-extension"
title = "My extension"
description = "Project commands"
version = "0.1.0"
api = 1
min_rozi = "0.0.25"
platforms = ["linux", "macos"]
homepage = "https://github.com/you/rozi-my-extension"| Field | Required | Meaning |
|---|---|---|
id | yes | Stable identifier, matching [a-z0-9_-]+. |
api | yes | Extension API version. Must be 1. |
title | no | Display name. |
description | no | One-line summary. |
version | no | Version shown in lists and update reports. |
min_rozi | no | Oldest rozi version the extension works with. |
platforms | no | Operating systems it runs on: linux, macos, windows, freebsd, netbsd. Omit for all. |
homepage | no | An http or https URL where users can read more. rozi displays it but never fetches it. |
screenshots | no | Up to four images for the Extension gallery card, as paths from the repository root, such as "docs/picker.png". rozi itself never shows them. |
The IDs app, command, rozi, user, and workspace are reserved. Keep id stable: every command, service, tab, and agent the extension contributes is named after it, and users' bindings and settings refer to it.
api must match exactly, while min_rozi means "this version or newer". If min_rozi is newer than the running rozi, or this machine is not in platforms, the extension loads as incompatible, with the reason stated. Nothing runs, and rozi extensions check says why.
An unrecognized platform name, a min_rozi that is not a version, or a homepage that is not an http(s) URL makes the manifest invalid, not incompatible, so a typo is reported rather than silently excluding the extension. When several problems apply, rozi reports the most specific:
- a mismatched
api; - any other manifest error, including those in the fields above;
min_roziorplatformsexcluding this machine.
rozi extensions list --verbose prints min_rozi, platforms, homepage, and screenshots when declared, rozi extensions check shows homepage, and the --json output of both includes all four.
Versions of rozi released before these fields existed reject a manifest that uses them as an unknown field. To stay loadable by those versions, leave them out.
The manifest schema is schemas/extension.schema.json. One invalid command, service, agent, sidebar tab, navigation target, suggested keybinding, or setting makes the whole extension invalid; rozi loads an extension whole or not at all.
Commands
A command runs when the user invokes it from the command palette, a key binding, or the CLI:
[[commands]]
id = "choose"
label = "Choose item"
exec = ["python", "{extension_dir}/bin/choose.py"]rozi run-action my-extension.chooseA command ID must match [a-z0-9_-]+. Its public ID is <extension-id>.<command-id>, such as my-extension.choose. Each command declares exactly one action:
| Field | Meaning |
|---|---|
exec = ["program", "arg"] | Run the program directly with these arguments, without a shell. |
shell = "command" | Run the command line through command_shell. Use only when you need shell syntax. |
send = "text" | Send text to the target pane. |
Prefer exec: it preserves argument boundaries and avoids shell interpretation.
Set hidden = true to leave a command out of the command palette until the extension shows it; see Show a command only when it applies.
- Commands run in the focused pane's working directory when that pane is on the machine running the command. By default a command runs on the client, so in a session attached with
--remoteit starts inrozi's own directory: the pane's path belongs to the remote host, even when the same path exists locally. To run it on the session's host instead, in that pane's directory there, see Run on the session host. - An executable path starting with
./or../resolves from the extension directory when the manifest loads. {extension_dir}is replaced insideexecarguments.$VAR,${VAR}, and%VAR%are not expanded.- An
execorshellcommand gets no input. Its output goes to the extension's log, not to a pane. - Started from the command palette, an
execorshellcommand keeps the palette open until it exits or opens a picker, so a picker replaces the palette without the panes flashing in between. If the command is still running after a moment, the palette shows that it is running.Esccloses the palette; the command keeps running. - If a command exits with a non-zero status,
rozishows an error notification. A command that has already reported its failure withrozi notifyshould exit0to avoid a second, vaguer message.
A command can also suggest a key; see Suggested keybindings.
Show a command only when it applies
Some commands are worth offering only in some situations, such as an installer whose work is already done. The extension knows that, so it decides at runtime:
[[commands]]
id = "install-hooks"
label = "Install hooks"
exec = ["python", "{extension_dir}/bin/install_hooks.py"]
hidden = true"$ROZI_BIN" show-command install-hooks # list it in the command palette
"$ROZI_BIN" hide-command install-hooks # and take it out again- Only the command palette honors this. A hidden command still runs from a key binding and from
rozi run-action. - An extension shows or hides only its own commands, by manifest
idor public<extension-id>.<command-id>, and only from its own commands and services. Any other caller is refused. - The choice lasts as long as the extension's runtime generation, even after the command that made it exits. A process-facing change (a changed command, service, or setting) rotates the generation and restarts its services, resetting the choice to the manifest's
hidden. Disabling and enabling the extension also starts a new generation. A presentation-only or no-op reload keeps both the generation and the choice. - An older
rozirejects an unknown manifest field such ashidden. Setmin_rozito a release with thecommand-visibilitycapability (see Check the installed API).
Services
A service is a long-running program that rozi starts and supervises:
[[services]]
name = "watch"
exec = ["python", "{extension_dir}/bin/watch.py"]
restart = "on-failure"
[services.env]
POLL_SECONDS = "30"A service name must match [a-z0-9_-]+, and its public ID is <extension-id>.<service-name>. It declares exactly one of exec or shell.
| Field | Type | Default |
|---|---|---|
name | string | required |
exec | string array | mutually exclusive with shell |
shell | string | mutually exclusive with exec |
cwd | path string | extension directory; a relative path resolves from there |
restart | on-failure, always, or never | on-failure |
env | string map | empty |
placement | client, active-session, each-host, each-session, or session | client |
session_scope | active or attached; attached needs each-host or each-session | active |
By default, services run in the client. active-session, each-host, and each-session run them on the hosts of attached sessions. These services start while a UI with the extension is attached, receive that UI's control environment, and stop when the client detaches or the extension is disabled or removed. A reload that changes a process-facing field or setting, any file in the extension directory other than extension.toml, or the checkout itself (as rozi extensions update replaces it) restarts the service; editing only title, description, or version in place leaves it running. An extension of more than 32 MiB or 4096 files is not checked for file changes. Use placement = "session" and deploy the extension for a service that must keep running after every client detaches.
Let rozi supervise the process rather than daemonizing it or writing your own restart loop. Use a service for long-lived work such as rozi subscribe or rozi publish. Extensions cannot declare hooks.
A service's output, and the reason it stopped, are in its log. When a service fails to start or keeps failing, the notification names that log.
An extension's published rows are activities unless its stream opens with --kind agent, which it should do only when each row is an agent's run or conversation. Each extension has its own publisher identity in a pane, so its rows never replace another publisher's; give a second stream in the same pane a --name. See Several publishers in one pane. Agent publishers can send final responses alongside completed rows in the same update, using the responses array. Those responses are available through agents prompt --result and agents response. An older rozi ignores responses, so an extension that depends on them sets min_rozi to the first release with the agent-responses capability, 0.0.44.
A service's stdin stays open while its client runs and reaches end of file once the client is gone, even if it was killed or crashed. Watch stdin and exit at end of file, so the service does not outlive a client that could not stop it. In that case, Linux also kills the service process, but not processes it started, and Windows kills the service with everything it started.
Run on the session host
By default, an extension's commands, services, and command tabs run on the client, the machine showing rozi. A contribution that works with a session's files, tools, or agents can run on that session's host instead, with placement. When you attach to a remote machine with --remote, a placed contribution runs there, in the remote session's directories and with its programs, and talks to your client through rozi as if it ran locally.
[[services]]
name = "watch"
exec = ["python3", "./bin/watch.py"]
placement = "each-host"
[[commands]]
id = "open"
label = "Open in project"
exec = ["./bin/open"]
placement = "active-session"placement | Where it runs | Allowed on |
|---|---|---|
client | On the client. The default. | commands, services, command tabs |
active-session | On the host of the session on screen. A service follows that session: switching sessions restarts it beside the new one. | commands, services, command tabs |
each-host | One instance on every host with an attached session, including the client itself when a local session is attached. Several sessions on one host share it. | services |
each-session | One instance per attached session, on that session's host. | services |
session | One instance owned by each session you explicitly deploy the extension to. Runs with no UI attached. | services |
A placed contribution runs from a copy of the extension's files, not from an installation on the host:
- When the extension loads,
rozitakes a snapshot of its directory and identifies it by a content digest. A host receives exactly that snapshot, checks it against the digest, and stores it read-only. Nothing needs to be installed on the host exceptroziitself. - The snapshot leaves out
.git,.hg,.svn,__pycache__, and.pycfiles, and is limited to 32 MiB and 4096 files. A symbolic link is copied as the file it points to. A link to a directory or to anything outside the extension, or a directory past the limits, makes an extension with placed contributions invalid. - Editing a linked extension's files and reloading takes a new snapshot, and placed processes restart from it under a new generation.
- The host checks the snapshot again before every launch. If a file has changed or regained write permission, the host discards it and the client sends a fresh copy before anything runs.
Placed contributions name their files relative to the extension, because the client's paths mean nothing on another machine:
- A direct
execprogram is either a name looked up on the host'sPATHor a./path inside the extension. An absolute path,~, or a path that leaves the extension is invalid. {extension_dir}andROZI_EXTENSION_DIRname the snapshot's directory on the host.- A placed service's
cwdmust be a directory inside the extension; it defaults to the extension's root. A placed command or command tab runs in the focused pane's directory on the session's host. When that directory does not exist there, it runs in the runtime's own directory instead: the user's home directory on a remote host. sendcommands, and launcher tabs, already act in a pane and take noplacement.
platforms applies to the machine that runs each contribution. An extension limited to Linux can still load on a macOS client and run its placed contributions on a Linux host; its client contributions are left out.
Run a service without a UI
[[services]]
name = "watch"
exec = ["python3", "./bin/watch.py"]
placement = "session"
restart = "on-failure"Deploy it with rozi extensions deploy <ID> --session <NAME>. Use rozi extensions status --session <NAME> for service states and process IDs. Session services read settings from [extensions.<ID>] in the host's config, combined with their deployed manifest's defaults. Redeploy or restart the session to apply host setting changes. Deploying the same files with the same settings while its services are all running changes nothing: they keep running, calls keep going, and the reply says unchanged. Any other deploy restarts them, including when one has stopped. While the host's config cannot be read, a service that is due to start stays stopped, and status gives the reason.
These processes receive ROZI_SESSION_INSTANCE and find their session directly. Use ROZI_BIN for commands and subscriptions. They can use commands supported by a session endpoint, except extensions deploy and extensions undeploy. Commands needing a UI, including pick and run-action, are refused. There is no focused pane, so pane commands must name a target.
Use rozi notify for a session notification. Every attached UI receives it, and a late UI receives the retained inbox. Subscribing to session-notification also works without a UI.
Their ROZI_EXTENSION_CREDENTIAL comes from the session and is valid only there. Client-issued credentials and client generation claims are refused. A redeploy that changes something, undeploying, renaming the session, or restarting retires old credentials and closes their open subscriptions and pending requests. Do not copy or log credentials.
Availability
A placed contribution never falls back to running on the client. When a host cannot run it, rozi shows a notification with the reason, once per reason, and the contribution stays unavailable:
| Reason | Meaning |
|---|---|
unsupported-platform | platforms excludes the host's operating system. |
runtime-unsupported | The host's rozi is too old to run extension processes or to give them a state directory. Update rozi on the host. |
missing-executable | The program is not on the host's PATH. Install it, then run reload-extensions. |
bundle-failed | The extension's files could not be stored or verified on the host. |
runtime-unreachable | The SSH connection to the host failed or dropped. rozi reconnects with backoff. |
no-session | An active-session contribution ran with no session attached. |
spawn-failed | The process could not be started for another reason. |
rozi extensions status lists every host rozi runs placed processes on and every placed process: where it runs, its generation, the digest of the files it runs from, its process ID, and why anything is unavailable. --json prints the same report, and rozi extensions list --verbose and --json include it when a UI is running.
Lifecycle
For each host, the client starts one extension runtime: in-process for the client's own machine, and over a separate SSH connection for a remote host. A remote runtime belongs to the client that started it. It ends, with everything it runs, when the client detaches the host's last session, exits, or loses the connection. Two clients attached to the same host each run their own.
Session services belong to an explicitly deployed extension. They start when deployed and return when the session restarts or is resurrected. Client reloads, attaching, and detaching do not change them. A redeploy that changes something, or renaming the session, restarts them under a new generation, and undeploying or deleting the session stops them. Services do not change the session's lifetime policy. If the session server is killed outright, a process a service started may outlive it, so a service that starts helpers should have them exit when it does.
A placed service follows its restart policy like a client one. Disabling, removing, or changing the extension stops its placed processes on every host.
What a placed process may do
A placed process uses the normal rozi commands through ROZI_BIN and ROZI_SOCKET, including streams such as rozi publish, rozi sidebar publish, rozi subscribe, rozi pick, and rozi prompt. Each process receives a credential in ROZI_EXTENSION_CREDENTIAL that tells the client which extension, generation, and host it was started for. The rozi CLI sends it automatically; never pass it on or log it. The client ignores any other identity a request claims, and refuses a credential that was revoked, that belongs to a retired generation, or that arrives from a different host.
What the client honors depends on the command:
pick,prompt,notify, andshow-command/hide-commandwork anywhere. Pickers, prompts, and notifications from a remote host name that host.sidebar publishworks anywhere, into the extension's own stream tabs. Its rows are headed with the process's host and, for a session-bound placement, its session.- Commands that act on panes, such as
list-panes,capture-pane,send-text,send-keys,split,focus,publish,agents list, andagents prompt, apply only while the session on screen is on the process's host (and, for anactive-sessionoreach-sessionplacement, is its session). Otherwise they fail without-of-scope, andlist-panesandagents listreturn nothing. Panes of the scratchpad and popups, which always run on the client, are never reachable. run-actionruns only the extension's ownactive-sessioncommands.- Anything that could start a process on the client or read the whole client fails with
not-permitted: other actions, popups, UI captures, recordings, pane logging, metrics,extensions status,agents list --all,agents go, andagents report/release. subscribereceives only events that happen while a session within the process's binding is on screen.
These rules apply to placed processes on the client's own machine too, so a placed contribution behaves the same wherever it runs.
A service that keeps rows about a session can ask to go on doing so while that session is off screen, with session_scope = "attached" in its manifest:
[[services]]
name = "watch"
exec = ["python3", "./bin/watch.py"]
placement = "each-host"
session_scope = "attached"It then also may:
- List the panes of every attached session within its placement with
rozi list-panes --scope attached. - Publish rows into any of those sessions. Open the stream with
ROZI_PANEandROZI_SESSION_INSTANCEset to the pane'sidand itsreference.session_instance: pane IDs repeat across sessions, so both are required.
The service runs the host's rozi, so that host needs the attached-session-scope capability as well as the client (see Check the installed API). min_rozi checks only the client. On an older host the service does not start, and rozi extensions status says to update Rozi there.
Every other command stays on the session on screen, so such a service cannot type into, read the screen of, or move a pane of a session you are not looking at. Check a pane's on_screen before naming it in one of those commands. Use the session instance rather than the session name to tell sessions apart: names can change and repeat.
Security
Placement is not a sandbox. The rules above limit what a placed process may ask rozi to do. The process itself runs as the user on its host, like any program they start, and can do anything their account can there. An extension of any placement is software the user trusts; see Extensions.
The trust boundary is the host, not the process. Any process running as the same user on a host can read the credentials of the placed processes there, and can change their files, so a credential does not tell one process on a host from another. What it does is tie a request to one host, one extension, and one live generation: it stops a retired or restarted process from acting, keeps one host from speaking for another, and attributes requests to the extension that made them.
Treat a remote host as able to do anything the placed processes on it together may do, as described above, and nothing more. That is what the client enforces, whatever runs on the host: every request is checked against those rules, a host's runtime can only answer for the processes it was asked to start, the resources a host can make the client hold are bounded, and nothing a host sends can start a process on the client. A host that breaks the protocol or goes past a bound is disconnected.
The snapshot's digest checks integrity rather than security. Its read-only copy protects against accidental changes, and verification before every launch catches any change, deliberate or not, but a process on the host that can change the snapshot can also change what runs there in other ways.
Placed contributions need rozi with the remote-extension-runtime and extension-state-dir capabilities on the client and on every host they run on. Set min_rozi to a release that has it (see Check the installed API); an older rozi rejects the placement key.
Actions
An action is an operation other programs and extensions can call with rozi invoke. It takes JSON arguments and answers with a JSON result, and it runs in the session it is deployed to, whether or not a UI is attached. A command behaves as if a person ran it and answers nothing. Use an action when the caller needs an answer. [[actions]] comes with the session-actions capability.
[[actions]]
id = "run-tests"
title = "Run tests"
exec = ["python3", "./bin/run-tests.py"]
args_schema = { type = "object", required = ["package"], properties = { package = { type = "string" } } }
result_schema = { type = "object", required = ["passed"] }
deadline_ms = 120000
concurrency = "singleton"| Field | Type | Default |
|---|---|---|
id | string | required; called as <extension>.<id> |
title | string | required |
exec | string array | required; a name on the host's PATH or a ./ path inside the extension |
args_schema | JSON Schema | required; {} accepts anything |
result_schema | JSON Schema | required; {} accepts anything |
deadline_ms | 1 to 600000 | 30000 |
concurrency | parallel or singleton | parallel |
placement | session | session |
Deploy the extension with rozi extensions deploy <ID> --session <NAME>, then call the action:
rozi --session work invoke git-tools.run-tests --args-json '{"package":"rozi"}'
rozi --session work actions list --format json # every deployed action, with its schemasEach call starts one process, in the deployed extension's directory. It reads its arguments from stdin and prints one JSON value to stdout, then exits with status 0. Anything else it wants to say goes to stderr, which is kept in the extension's log. Arguments and results may each be at most 512 KiB, and each is checked against its schema. A schema cannot refer to anything outside itself.
#!/usr/bin/env python3
import json, sys
args = json.load(sys.stdin)
print(json.dumps({"passed": run_tests(args["package"])}))A process receives the session service environment, plus ROZI_ACTION and ROZI_INVOCATION_ID. It runs no UI: pick and prompt are refused.
An action, or a session service, calls another action the same way, with $ROZI_BIN invoke. Calls may nest 8 deep, and a nested call must end before its caller's deadline, whatever its own says. A session runs at most 8 calls at once; another call fails instead of waiting. So does a call to a singleton action that is already running.
A call ends when it reaches its deadline, when its caller disconnects, when someone runs rozi invocation cancel, when a redeploy changes the extension, when it is undeployed, or when the session is renamed. The deadline covers the whole call, from the request until its result has been checked, so starting the process and reading its output use up the same time.
When a call ends, its process and the processes it started that are still in its process group are killed. On Windows, that is everything it started. A process that detaches itself from its group, for example with setsid, can outlive the call. If it also keeps stdout open, the call fails, or times out if its deadline comes first, so close stdout in anything you leave behind.
An action that accepts {} appears in the command palette of UIs attached to that session, except read-only ones. The palette calls it with {} and reports whether it finished.
Settings
Declare the settings an extension understands, each with its default value:
[settings]
runner = "auto"
rows = 50
notify = true
ignore = ["target", "node_modules"]A setting is a string, integer, boolean, or list of strings. Any other type, including floats, makes the extension invalid. rozi extensions check lists the declared settings and their defaults.
To say how a setting reads in rozi's tab settings, declare it as a table with its default and any of these fields:
[settings]
group = { default = "status", label = "Group by", choices = ["status", { value = "repo", label = "Repository" }] }
rows = { default = 50, label = "Rows", description = "Rows kept per job", min = 1, max = 500 }
done = { default = false, label = "Show finished" }| Field | Applies to | Meaning |
|---|---|---|
default | every setting | Required. The value when the user sets none, and the setting's type. |
label | every setting | The setting's name in the picker. Defaults to the key. |
description | every setting | One line shown under the list while the setting is highlighted. |
choices | strings | The only values allowed: each a string, or { value, label } to show other words. default must be one of them. |
min, max | integers | Bounds. default must lie within them. |
A table with an unknown field, choices on a non-string, bounds on a non-integer, or a default outside its choices or bounds makes the extension invalid. The value your processes receive is the same either way. An older rozi rejects the table form; set min_rozi to a release with the sidebar-tab-settings capability (see Check the installed API).
Users override settings per extension in config.toml:
[extensions.tasks]
runner = "just"
rows = 20Every command, service, and sidebar tab process receives the merged result as compact JSON in ROZI_EXTENSION_CONFIG:
{"ignore":["target","node_modules"],"notify":true,"rows":20,"runner":"just"}An undeclared key, a value of the wrong type, or a value outside its choices in the user's config is reported and ignored, and the extension keeps its default; a leftover line therefore survives an update that drops a setting. An integer outside its min and max is clamped. A [extensions.<id>] table for an extension that is not installed is also reported, but a disabled extension's table is not. Changing a setting restarts the extension's services with the new value.
Suggested keybindings
An extension can suggest keys in two ways. Both are resolved once, when extensions load; key input still goes through rozi's own command registry, and extension code never sees key events.
Suggest a chord for a command
A command's key field suggests a chord inside the <prefix> x space, which rozi reserves for extensions:
[[commands]]
id = "run"
label = "Run task…"
exec = ["python", "{extension_dir}/bin/tasks.py", "run"]
key = "r"With the default prefix, that command answers to Ctrl+A, then x, then r. rozi binds nothing to x itself, so a suggestion never collides with a built-in and a later release cannot take it away.
The suggestion loses to anything already bound, including any longer chord it is a prefix of. Losing produces a warning and nothing else: the command stays in the palette, and the user can bind it directly. An explicit [keys] entry for the command always wins and silences the suggestion:
[keys]
"tasks.run" = "ctrl-a t"Suggest a key for a core action
[[suggested_keybindings]] suggests a key for a core action that rozi exposes to extensions:
[[suggested_keybindings]]
action = "smart-focus-left"
key = "ctrl-h"Extension API 1 exposes only smart-focus-left, smart-focus-down, smart-focus-up, and smart-focus-right. Any other action makes the manifest invalid.
rozi resolves bindings in this order:
- explicit
[keys]configuration, including an empty list that unbinds the action; - built-in defaults;
- extension suggestions.
- Configuring the target action suppresses its suggestion, even when the suggested key is free.
- A key already used by the user or by a default makes the suggestion an inactive conflict.
- Identical suggestions for the same action and key are merged.
- If extensions suggest different actions for the same free key, none of them wins, and each is reported as a conflict.
- Different free keys for the same unbound action can coexist.
A conflicting suggestion stays inactive but does not invalidate the extension. Disabling, removing, or reloading an extension rebuilds the keymap without its suggestions. rozi extensions list --verbose and Extensions… show each suggestion's source extension and whether it is active, suppressed, or conflicting.
Sidebar tabs
An extension can add sidebar tabs with [[sidebar_tabs]]. A tab takes the launcher, command, and stream forms that [sidebar] tab tables accept, and can offer some of the extension's settings with settings; see Tab settings.
[[sidebar_tabs]]
name = "agents"
label = "Agents"
entries = [
{ label = "rozi", group = "claude", run = "cd ~/Projects/rozi && claude" },
{ label = "rozi", group = "codex", run = "cd ~/Projects/rozi && codex" },
]
[[sidebar_tabs]]
name = "worktrees"
label = "Worktrees"
command = "git-tools worktrees --sidebar"
interval = 30
group_prefix = "## "
on_click = { send = "{line}" }| Field | Type | Default |
|---|---|---|
name | string | required, [a-z0-9_-]+ |
label | string | required |
entries | array of tables | exactly one of entries, command, or stream = true |
group (per entry) | string | none |
command | string | exactly one of entries, command, or stream = true |
format | lines or json | lines; command tabs only |
stream | bool | false |
interval | integer seconds | 30, minimum 5 |
on_click | action table | none |
group_prefix | string | none; line command tabs only |
placement | client or active-session | client; command tabs only |
settings | array of setting keys | none |
{extension_dir}is replaced incommandand in action strings, and the processes they start receive the sameROZI_EXTENSION*environment as a command.- A command tab runs in the focused pane's working directory and re-lists when that directory changes. Like a command, it runs on the client unless it has a
placement, so by default it uses no pane directory in a--remotesession. placement = "active-session"runs a command tab'scommand, and anon_clickexec, on the host of the session on screen; see Run on the session host.- Every line a command tab prints is a clickable row unless it starts with
group_prefix, which makes it a section header. Print status and empty-state lines with the prefix. on_clickwithsendmay use{line}. Withrun,popup, orexec, the clicked row arrives inROZI_ROWinstead; quote it ("$ROZI_ROW").- A command tab with
format = "json"prints a tree of rows; a clicked row'sidalso arrives inROZI_ROW_ID. See JSON command tabs.
Tab settings
settings lists settings the tab offers in its settings picker, which users open with Ctrl+E in Sidebar tabs…. The picker lists them in this order, edits them in [extensions.<id>], and puts one back to its default with Ctrl+R:
[settings]
group = { default = "status", label = "Group by", choices = ["status", "repo"] }
done = { default = false, label = "Show finished" }
[[sidebar_tabs]]
name = "jobs"
label = "Jobs"
stream = true
settings = ["group", "done"]A boolean shows as Enabled or Disabled, a string with choices as a list of its values, and an integer or other string as a typed value. Each key must be a declared setting, listed once, and not a list of strings; otherwise the extension is invalid. Several tabs may offer the same setting. An older rozi rejects settings on a tab, as it does the table form above.
A change is the same as the user editing config.toml: it reaches every process in ROZI_EXTENSION_CONFIG, so a command tab's next run reads it and services restart with it. Read the value there rather than tracking it per tab.
Stream tabs
A stream tab's rows come from the extension's own processes, usually a service, through rozi sidebar publish. The process names the tab by its local name; it cannot publish to another extension's tab or to one in config.toml.
[[sidebar_tabs]]
name = "machines"
label = "Machines"
stream = true
[[services]]
name = "machines"
exec = ["{extension_dir}/machines-watch"]
placement = "each-host"# machines-watch: one snapshot per change, and read what the user did on stdout.
rozi sidebar publish --tab machinesA tab can have several producers at once. With placement = "each-host", every host's instance publishes its own rows to the same tab, and the tab heads each with its host, so a row called main on two machines stays two rows. With each-session or active-session, the heading also names the session. Each producer's rows, actions, and events are its own: a click on a host's row is reported only to that host's instance, and an instance that stops takes only its rows with it. A producer restarted in the same place replaces its earlier rows. Several processes in one place tell themselves apart with --name.
Stream tabs take no placement; place the services that publish to them instead.
A tab's public ID is <extension-id>.<name>, so an extension can add tabs but never replace a built-in one. If a config.toml tab has the same ID, it wins and the extension's tab is skipped. Out-of-range values are clamped without a warning, unlike the same settings in config.toml.
An extension tab may declare suggested_location = { dock = "right", panel = 2 }, with a one-based panel number from 1 to 3. An explicit saved placement wins. Tabs without a suggestion start in the first panel of the left dock, or of the right dock when only it holds tabs. The user's hidden state and placement survive disabling, failed loads, updates, removal, and reinstallation. Unavailable tabs remain listed in Sidebar tabs….
Extensions may also declare [[sidebar_presets]] with name, label, and layout. The layout uses the same left, right, and hidden fields as [sidebar] layout. Names of this extension's tabs are automatically namespaced inside panel tab lists. Preset names become <extension>.<name>. Users select and apply a preset through Settings… → Bars → Sidebar layout preset…; installing or reloading an extension never applies one. Applying copies the preset into user preferences, preserving saved assignments omitted by the preset. Presets leave client visibility and startup preferences unchanged. Later edits belong to the user.
[[sidebar_presets]]
name = "review"
label = "Review workspace"
layout = { right = { panel_count = 1, panels = [{ weight = 1.0, tabs = ["agents"] }] } }Navigation targets
An extension can tell rozi which foreground programs manage their own splits, so split-aware navigation forwards focus keys to them:
[[navigation_targets]]
name = "vim"
programs = ["vim", "nvim", "view", "vimdiff"]name identifies the declaration within the extension and must match [a-z0-9_-]+. programs lists executable basenames, not paths. Names are trimmed and matched case-insensitively, without a Windows .exe suffix. Duplicates, within one declaration or across built-in and extension targets, are harmless.
These declarations are data. rozi validates them when the extension loads; the extension runs no code, receives no keys, and takes no part in detecting the foreground program. Disabling or removing the extension drops its targets on the next extension reload.
An explicit user list replaces every built-in and extension target:
[navigation]
editors = []When editors is absent, enabled extension targets add to the built-in list.
The vim-rozi-navigator repository pairs this declaration with its Vim and Neovim plugin. Install the rozi side directly:
rozi extensions install https://github.com/tui-lipan/vim-rozi-navigator.gitAgent definitions
An extension can include [[agents]] entries in the same format as user agent definitions, to teach rozi to recognize a coding-agent CLI and read its state. Each agent ID is namespaced as <extension-id>.<id>, so an extension can add an agent but cannot replace a built-in one.
Link handlers
Declare [[link_handlers]] to route terminal clicks and uppercase hint selections to one of your extension's commands:
[[commands]]
id = "open"
exec = ["python3", "{extension_dir}/bin/open.py"]
[[link_handlers]]
schemes = ["http", "https"]
command = "open"Add files = ["*.png", "*.jpg"] to a file handler to open only files whose names match, as in [[links.handlers]]. command is the local command ID, not a namespaced ID. It must name a process command in this manifest; send commands and commands belonging to another extension are invalid. Empty or invalid URI schemes invalidate the whole extension. Matching ignores case. User handlers precede extension handlers; extensions are ordered by installation directory name and handlers by declaration order.
The command receives the link context documented in Link handlers, including ROZI_URL, ROZI_FILE, ROZI_HINT, ROZI_SOURCE_PANE, and ROZI_SOURCE_SESSION. Its ordinary extension settings and generation fencing still apply. A command placed on the active session receives the same context on that host and starts in the source pane's directory. Command references resolve at invocation time, so disabling, removal, or invalid-manifest reload cannot leave a callable stale handler. These are static declarations; no extension runs on mouse movement or terminal rendering. Check rozi api describe for extension-link-handlers before relying on this additive extension API 1 feature.
Keep state
Every extension process receives ROZI_EXTENSION_STATE_DIR, a directory where the extension can keep files between runs. rozi creates it before starting the process, readable only by the user. It is the same directory for every process of the extension on one machine: commands, services, command tabs, link handlers, and placed processes.
import json, os
from pathlib import Path
path = Path(os.environ["ROZI_EXTENSION_STATE_DIR"]) / "history.json"
history = json.loads(path.read_text()) if path.exists() else []The directory belongs to the extension ID and the machine, and nothing else:
- It survives updates, reloads, disabling and enabling, reinstalling, and removal.
rozi extensions remove <id> --purgedeletes it on the machine where you run it. - It is never copied, never part of a placed contribution's snapshot, and never moved between machines. A placed process uses the state directory of the host it runs on, so an
active-sessioncontribution keeps its state on the host of the session it ran for, not with you. Attaching to another host gives it that host's state. - Every process of the extension on a machine shares it, including
each-sessioninstances and processes started by differentroziclients. Use your own locking or atomic writes (write a temporary file, then rename it) when more than one process may write. - To keep state per session, name files after
ROZI_SESSION_NAME, the name of the session the process works for; see Runtime environment for which processes have one.
Settings stay in [settings] and ROZI_EXTENSION_CONFIG; the state directory is for what the extension itself writes.
Runtime environment
Every extension command and service receives:
| Variable | Value |
|---|---|
ROZI_EXTENSION | The extension's manifest ID. |
ROZI_EXTENSION_DIR | Absolute installation directory. |
ROZI_EXTENSION_STATE_DIR | The extension's state directory on this machine. |
ROZI_EXTENSION_CONFIG | Merged settings as a compact JSON object; {} when none are declared. |
ROZI_EXTENSION_GENERATION | Opaque token identifying the currently loaded extension. |
ROZI_BIN | The running rozi executable, when available. |
ROZI_SOCKET | The current UI's control endpoint, when available. |
An action also receives ROZI_ACTION and ROZI_INVOCATION_ID.
A placed contribution receives the same variables, with these differences:
| Variable | Value |
|---|---|
ROZI_EXTENSION_DIR | The extension's snapshot directory on the host that runs it. |
ROZI_EXTENSION_STATE_DIR | The extension's state directory on the host that runs it. |
ROZI_BIN | The host's rozi executable. |
ROZI_SOCKET | The host's extension runtime, which relays requests to your client. |
ROZI_EXTENSION_CREDENTIAL | The process's own credential. Never pass it on or log it. |
ROZI_SERVICE | The service's public ID, for a placed service. |
Client exec commands receive ROZI_SOURCE_PANE and ROZI_SOURCE_SESSION for the focused pane and its session instance. Link handlers receive these variables for the source pane, along with the link context, for both client and placed commands. Ordinary run and popup commands and services do not receive these variables automatically.
ROZI_SESSION_NAME holds the name of the session a process works for. Client exec commands, link handlers, and command tabs receive the session on screen, and an active-session or each-session contribution receives its own session. It is empty when no session is attached. Services that run on the client and each-host services serve no single session and do not receive it. Renaming a session restarts its active-session and each-session services with the updated name.
For a session attached with --remote, run panes outside the scratchpad and all popup commands open on the remote host. Neither receives ROZI_EXTENSION_STATE_DIR. A run pane in the scratchpad opens on the client and receives the client's state directory.
A service's env cannot override the ROZI_EXTENSION* variables or set ROZI_TERMINAL, which only a process running in a pane receives. The runtime replaces any ROZI_BIN, ROZI_SOCKET, or ROZI_EXTENSION_CREDENTIAL a placed service's env sets.
Call rozi through ROZI_BIN rather than assuming it is on PATH, and pass ROZI_SOCKET back to it:
"$ROZI_BIN" --socket "$ROZI_SOCKET" notify "extension task finished"When ROZI_EXTENSION is set, the rozi CLI tags each request with the extension's identity and generation. After the extension is disabled or reloaded, requests from its old processes are rejected; a process whose request is rejected should exit rather than retry. For a client contribution, the generation keeps stale processes from acting, but it is not authentication: other processes running as the same user are not blocked. A placed contribution's requests carry its credential instead, which ties them to its host and generation; see Security. Pickers, activity rows, and subscriptions an extension opens close when its generation is retired.
A session server cannot check the generation, so it refuses requests from extensions; see "Extensions and --session" in Control. For stream ownership, see Control protocol, and for portable command examples, see Scripting.
Test and debug
Validate after every manifest edit:
rozi extensions check .Besides errors, check prints hints: the likely fix for a common mistake, such as a misspelled key, exec written as a string, or a script path that a command would look for in the focused pane's directory instead of the extension's. A hint does not make an extension invalid, so fix a hinted manifest even when it loads. check --json lists them in hints.
The report ends with Discover: whether the public index would list the extension, or what stops it. This checks catalog eligibility separately from install validity, so Ready can appear alongside errors in commands or services. Fix those errors before publishing. check --json reports the eligibility result in discover, as ready and problems. See Publish to Discover.
Then test in the isolated environment described in Extension testing. Do not test by copying unfinished code into your normal extension directory.
After changing a linked checkout:
rozi run-action reload-extensions
rozi extensions list --verboseFor placed contributions, rozi extensions status shows where each one runs and why one is unavailable.
If a command fails, rozi shows a notification that names the extension's log. To reproduce it yourself, run the resolved argv from the verbose output in an isolated test environment.
Read an extension's logs
What a command, a service, or a placed process writes to standard output or standard error is kept in a log, with a timestamped line when the process starts and when it ends. A service that rozi stops, because its extension was reloaded, disabled, or removed, is recorded as stopped:
rozi extensions logs my-extension
rozi extensions logs my-extension --unit service.watch --lines 200Without --unit, the command prints the last 50 lines of every log of the extension; --lines changes the count. Each kind of process has its own log, listed by its heading:
| Log | Holds the output of |
|---|---|
commands | exec and shell commands that run on the client |
service.<name> | The service with that name |
command.<id> | The placed command with that id |
tab.<id> | The placed sidebar tab with that id |
The name and ID are the ones in extension.toml, without the extension's own ID in front.
A log keeps only the most recent output, about 100 KB per log. An extension that prints a lot should print less or write its own file in its state directory.
If the command reports that another rozi is writing a log right now, run it again.
Logs belong to the machine the process ran on. A placed process logs on its host, so run rozi extensions logs there to read it. Removing an extension deletes its logs; --purge is only needed for state.
Publish to Discover
Listing a public GitHub repository in Discover needs no submission or review:
- Put
extension.tomlat the repository root. - Include
id,title,description,version, andapi. - Add
min_rozi,platforms, andhomepagewhen they clarify compatibility. Use distinct, exact lowercase platform names:linux,macos,windows,freebsd, ornetbsd. - Optionally commit screenshots and list them in
screenshots; the Extension gallery shows them on the extension's card. - Run
rozi extensions check .and fix everything its Discover section lists. - Push to the default branch, and add the
rozi-extensiontopic to the repository.
The index is rebuilt hourly. Each build reads the root extension.toml from the default branch, records the exact commit, and never runs the repository's code. Install in Discover installs that commit, and rozi validates the whole extension again before installing it. Pushing a new commit updates the listing at the next build.
A repository with the topic that a build leaves out is listed, with the reason, in skipped.json. check reports the same problems before you publish. It also needs extension.toml at the root of a Git repository. It cannot tell whether the repository is public, has the topic, or is archived, disabled, or a fork.
Being listed is not a review. The index only checks that the metadata is well formed, and anyone can list a repository, so Discover shows what it found, not what anyone vouched for. Read an extension's source before you install it; see Review an extension before installing.
The index keeps each entry short, and leaves out a repository whose metadata exceeds a limit:
| Field | Maximum length |
|---|---|
id, version, min_rozi | 64 characters |
title | 80 characters |
description | 280 characters |
homepage | 256 characters |
Screenshots must be PNG, JPEG, or WebP files committed to the repository, at most 2 MiB each. A path may use only ASCII letters, digits, ., _, -, and /. Cards show images at 16:10 and crop others from the top left. Run rozi in a terminal of 160×50 cells, set up the extension in use, and save rozi capture-ui --render png --scale 2 --output shot.png: the result is 2560×1600 pixels, exactly 16:10. Convert it to WebP to stay well under the size limit. See Capturing the whole UI.
The generated index and its schema are public at tui-lipan/rozi-extension-index.
Stability
Extension API 1 is frozen. Within API 1, rozi will not break:
- the manifest keys documented on this page, and the schema at
schemas/extension.schema.json; - namespacing: every contributed ID is
<extension-id>.<local-id>, and an extension can only add, never replace, a built-in; - navigation targets as static load-time declarations:
roziowns foreground-program matching and key forwarding, and an explicit[navigation] editorslist replaces all declarations; - suggested keybindings: they target only the documented action allowlist, rank below user and built-in bindings, and never put extension code on the input path;
- the
ROZI_EXTENSION*environment variables and their meanings, andROZI_SESSION_NAME; - the state directory: one per extension ID and machine, kept until purged;
placementand what a placed process may do, as described under Run on the session host;- the
rozicontrol commands documented in Control, and their exit codes; - atomic validity: an extension loads whole or not at all;
- the
<prefix> xchord space reserved for extension key suggestions; --jsondiagnostics withschema_version1.
rozi may add manifest keys, control commands, and diagnostic fields within API 1; a manifest that does not use them keeps working. A manifest that uses a key added later is invalid on an older rozi, which does not know the key; the page notes the capability each such key comes with. Any change that would invalidate a working manifest — a removed key, a narrowed value, a changed default, a renamed environment variable — requires api = 2, and an extension declaring api = 1 keeps loading under the API 1 rules.
In practice:
- read
ROZI_EXTENSION_CONFIGwith your declared defaults as fallbacks; - do not assume a suggested chord was granted;
- do not depend on behavior that this page and Control do not document.
Examples
Three examples build on each other, each with its own tests:
- Hello — a command, and a picker when you want one
- Project status — a service on the active session's host that subscribes to events, publishes an Activity row, and keeps per-host state
- Agent board — an agent definition, a sidebar stream tab, and a service that follows the active session, local or remote
The repository also includes these example extensions:
- Git tools — grouped branch and worktree pickers
- PR dashboard — a supervised pull request monitor
- Docker — container controls
- SSH tools — SSH host discovery and pane launch
- Agent activity — notifications and a picker for pane status
- Activity dashboard — general published activity
- Snippets — saved commands pasted into the focused pane
- Tasks —
just,make, andpackage.jsontasks, with a sidebar tab - Test loop — a session service and an action that run a project's tests when an agent finishes a turn, with no UI attached
See Automation recipes for smaller building blocks.