Appearance
Plugins
Plugins add your own features to Clusto without changing Clusto itself. A plugin is a folder (usually a git repository) with a clusto-plugin.toml manifest and the scripts it names. It can add:
- Views - pages in the sidebar, drawn by Clusto from JSON your script prints.
- Hooks - scripts that run when a session starts or exits, an agent goes idle or starts working, a chat turn completes, or a task run finishes.
- Agent tools - an MCP server that Clusto registers with Claude Code, Codex and Gemini CLI.
- Commands - actions in the F1 command palette, run in the background or in a terminal.
Plugins are available on every plan, including the free Personal plan.
Plugins are trusted code
A plugin runs native code with the full permissions of its run-as account on every node it is installed on. There is no sandbox. Install only plugins you trust, and read the manifest Clusto shows you before you approve it.
Turn plugins on
Plugins are an early access feature. Turn on Settings > Labs > plugins.user (or early access for the whole cluster). The Plugins section then appears in Settings, and the clusto plugin commands start working.
Install a plugin
From the app: Settings > Plugins > Install plugin. Enter a git URL (and optionally a branch, tag or commit) or pick a folder on a node, then Read plugin. Clusto shows everything the plugin adds and the exact commands it runs. Pick the nodes and the run-as account, then Install.
From the CLI:
sh
clusto plugin inspect https://github.com/you/clusto-deploys.git
clusto plugin install https://github.com/you/clusto-deploys.git --ref v1.2.0 --run-as deploy
clusto plugin install ./my-plugin # a folder on the connected node
clusto plugin ls
clusto plugin status # install state on the connected node
clusto plugin status --node <node-id>install prints what the plugin adds and asks before saving. Pass --yes to skip the prompt and --on <node-id>,<node-id> to install on some nodes only (the default is every node).
How installation works
- The plugin list is shared by the whole cluster. Each node it targets checks the plugin out on its own.
- A git plugin is pinned to an exact commit. Every node checks out that commit and nothing else.
- Clusto records a hash of the manifest you approved. A node refuses to run a checkout whose manifest differs, so new views, hooks, tools or commands always need a fresh approval.
- A folder plugin runs only on the node that holds the folder and is read in place. After you edit its manifest, approve it again with Update (or
clusto plugin update <id>). - Removing a plugin removes it from every node, with its agent tools. For a git plugin the checkout and its data folder go too; a folder plugin's own folder is never touched.
Update, disable, remove
sh
clusto plugin update deploys # latest commit of the default branch
clusto plugin update deploys --ref v1.3.0
clusto plugin disable deploys
clusto plugin enable deploys
clusto plugin rm deploys
clusto plugin logs deploysIn the app, each plugin in Settings > Plugins has Update, Log, Remove and an Enabled checkbox. Update shows a Manifest changed badge when the new version adds or changes anything.
Run-as account
Plugin processes never run as the daemon's own privileged account unless you name that account explicitly. Each node picks the account in this order:
- The plugin's run-as user for that node, set at install time (
--run-as). - The node's
default_session_user. - The daemon's own account, but only when the daemon does not run as root or SYSTEM.
If none applies, the plugin shows as Failed on that node with the reason. Git checkouts and every plugin process run as that account, and the checkout lives in that account's Clusto data folder (~/.local/share/clusto/plugins/<id>/<commit> on Linux).
The manifest
clusto-plugin.toml sits at the root of the plugin:
toml
id = "deploys" # a-z, 0-9 and -, unique in the cluster
name = "Deploys"
version = "1.2.0"
description = "Deploy status and one-click redeploys."
min_clusto = "1.16.0" # optional: oldest Clusto it works with
[[views]]
id = "status"
title = "Deploys"
icon = "rocket" # see the icon list below
run = ["./bin/view"]
[[hooks]]
event = "session.exited"
run = ["./bin/on-exit"]
[mcp]
run = ["node", "./mcp/server.js"]
env = { DEPLOY_ENV = "prod" }
[[commands]]
id = "redeploy"
title = "Redeploy this project"
context = "project" # global, project, session or node
mode = "background" # background (default) or session
run = ["./bin/redeploy"]Every run is a program and its arguments, run without a shell. A program with a slash (./bin/view, bin/view) is relative to the plugin folder and may not leave it; a bare name (node, python3) is looked up on PATH. Unknown keys are refused, so a typo fails at install time instead of being ignored.
Every process gets these variables:
| Variable | Value |
|---|---|
CLUSTO_PLUGIN_ID | The plugin id |
CLUSTO_PLUGIN_DIR | The plugin folder (the checkout) |
CLUSTO_PLUGIN_DATA | A folder for the plugin's own state; it survives updates. Create it before use |
CLUSTO_NODE_ID | The node the process runs on |
Requests arrive as one JSON object on stdin. Processes have a time limit (60 seconds for views, 300 for commands, 30 for hooks) and at most 1 MiB of output. What they write to stderr goes to the plugin log (clusto plugin logs <id> or Log in Settings).
Views
A view is a page in the sidebar. Plugin views join the menu as soon as the plugin is installed; move or hide them in Customize navigation like any other item. They also appear in the F1 palette as Go to followed by the view title.
When the page opens, Clusto runs the view's run with:
json
{"type": "render", "plugin": "deploys", "view": "status", "node_id": "...", "params": {}}params holds the page's query string (/plugins/deploys/status?env=prod gives {"env": "prod"}). When the user presses a button or submits a form, the same program gets:
json
{"type": "action", "plugin": "deploys", "view": "status", "node_id": "...", "action": "redeploy", "values": {"env": "prod"}}The program prints a reply with a new view, a toast, or both. An action reply without view keeps the current page:
json
{
"toast": {"message": "Redeploy started", "tone": "success"},
"view": {
"title": "Deploys",
"refresh_secs": 30,
"blocks": [
{"type": "heading", "text": "Production"},
{"type": "markdown", "text": "Last deploy **12 minutes** ago."},
{"type": "stats", "items": [{"label": "Healthy", "value": "12", "tone": "success"}]},
{"type": "kv", "items": [{"label": "Region", "value": "eu-central"}]},
{"type": "badges", "items": [{"text": "v1.42", "tone": "info"}]},
{"type": "table", "columns": ["App", "State"], "rows": [
{"cells": ["web", "live"], "actions": [{"label": "Redeploy", "action": "redeploy", "values": {"app": "web"}}]}
]},
{"type": "buttons", "items": [
{"label": "Roll back", "action": "rollback", "tone": "danger", "confirm": "Roll back production?"}
]},
{"type": "form", "fields": [
{"name": "env", "label": "Environment", "kind": "select", "value": "prod",
"options": [{"value": "prod", "label": "Production"}, {"value": "staging", "label": "Staging"}]},
{"name": "note", "label": "Note", "kind": "text", "required": true}
], "submit": {"label": "Deploy", "action": "deploy"}}
]
}
}| Block | Fields |
|---|---|
heading | text |
markdown | text - Markdown without raw HTML; remote images are click-to-load |
kv | items: label, value |
stats | items: label, value, tone |
badges | items: text, tone |
table | columns, rows: cells, optional actions (buttons) |
buttons | items (buttons) |
form | fields, submit (a button). Field kind: text, textarea, number, select (with options), checkbox. Fields take value, placeholder, required. Values are sent as strings; a checkbox sends "true" or "false" |
A button has label, action, and optionally tone, confirm (a question asked before the action is sent) and values (sent with the action; a form adds its field values). tone is neutral, info, success, warning or danger. refresh_secs re-renders the page every N seconds (at least 5) while it is visible.
A block Clusto cannot read is shown as an error in its place, so one mistake does not blank the whole view.
Icons: activity, bell, bot, box, bug, calendar, cart, chart, cloud, code, dashboard, database, file, gauge, git, globe, hammer, heart, list, lock, mail, package, puzzle, rocket, server, settings, shield, star, terminal, timer, users, wrench, zap. Any other name shows a puzzle piece.
A view renders on the connected node when the plugin is installed there, and otherwise on the first node it targets.
Hooks
Hooks run on the node where the event happens, with the event on stdin:
json
{"type": "event", "plugin": "deploys", "event": "session.exited", "node_id": "...", "data": {"session_id": "...", "exit_code": 0}}| Event | data |
|---|---|
session.started | session: the session (id, name, working directory, command, ...) |
session.exited | session_id, exit_code |
agent.idle | session_id, session_name, working_dir, detail - a terminal agent finished working |
agent.working | session_id - a terminal agent started working |
chat.turn_completed | chat_id, session_id, session_name, working_dir, detail |
task.run_finished | run_id, task_id, session_id, attempt, status, exit_code, started_at, finished_at |
Hooks are fire-and-forget: their output goes to the plugin log only. Each plugin runs at most 4 hooks at once; events that arrive while all 4 are busy are dropped and counted. Settings > Plugins shows how often hooks ran and failed.
Agent tools (MCP)
A plugin with an [mcp] section is registered as the MCP server clusto-plugin-<id> in the Claude Code, Codex and Gemini CLI settings of its run-as account, next to Clusto's own clusto server. It speaks MCP over stdio, like any local MCP server. env adds variables on top of the ones listed above. Disabling or removing the plugin removes the registration. Claude chats in Clusto list plugin servers among the account's MCP servers, so you can pick them per chat like any other server.
Commands
Commands appear in the F1 palette under Plugins:
globalandnodecommands are always offered.projectcommands are offered on a project's page and receiveproject_idanddir(the project folder).sessioncommands are offered on a session's page and receivesession_id,project_idanddir.
The request on stdin:
json
{"type": "command", "plugin": "deploys", "command": "redeploy", "node_id": "...", "target": {"project_id": "...", "dir": "/srv/app"}}A background command runs hidden; Clusto shows its output in a toast when it finishes. Print {"toast": {"message": "...", "tone": "success"}} to set the toast text yourself. A non-zero exit shows as a failure.
A session command (mode = "session") opens in a new terminal on the plugin's node, in the target's folder, running as the plugin's account. Since the terminal owns stdin, the request is in the CLUSTO_PLUGIN_REQUEST variable instead. Use it for interactive tools.
From the CLI, clusto plugin run <id> <command> [--project <id>] [--session <id>] runs a background command and prints its output, and clusto plugin view <id> <view> prints a view's reply as JSON, which helps while writing a plugin.
Time connectors
A plugin can send time tracking entries to another system and let the Time view search that system's tasks. Declare a connector in the manifest:
toml
[[time_connectors]]
id = "taskrequest"
name = "TaskRequest"
run = ["node", "./src/connector.ts"]
search = truesearch = true means the connector answers task searches for the Link picker. The connector shows up in the Time view's Sync tab, where you choose the node that runs it. It runs on that node with the plugin's run-as account and environment. Every request is one JSON object on stdin, and the connector prints one JSON reply on stdout. Each request also carries plugin, connector and node_id.
Search for tasks (60 second limit):
json
{"type": "search_tasks", "query": "login bug", "limit": 20}json
{"tasks": [{"id": "456", "title": "Fix login", "subtitle": "Backend", "url": "https://..."}]}subtitle and url are optional.
Sync entries (300 second limit, batches of 50):
json
{"type": "sync",
"entries": [{"entry": {...}, "project": {...}, "previous": {...}}],
"removed": [{"entry": {...}, "previous": {"external_id": "...", ...}}]}project is the entry's project or null. previous is the last sync record for the entry, or null the first time. The reply has one result per entry:
json
{"results": [{"entry_id": "...", "status": "synced", "external_id": "w-17", "hours": 1.5, "message": null}]}status is synced, failed or skipped. external_id, hours and message are optional.
A time entry has these fields: id, description, project_id, tags, start, end, owner, links, source, removed, created_at and updated_at. A link is either {"kind": "azure_devops", "account_id", "org", "id", "title"?} or {"kind": "connector", "plugin_id", "connector_id", "id", "title"?, "url"?}. A source is {"kind": "toggl", "id": N} for an entry imported from Toggl.
Delivery rules:
- An entry is sent again whenever it changes.
- A failed entry is retried on the connector's interval.
- A removal is sent while the entry's last record holds an
external_id. - A result missing from the reply counts as failed.
A non-zero exit with {"error": "..."} on stdout shows that message in the Sync tab.
A TaskRequest connector plugin exists for the maintainer's own use; it is a worked example of this contract.
A complete small plugin
toml
# clusto-plugin.toml
id = "hello"
name = "Hello"
version = "0.1.0"
[[views]]
id = "main"
title = "Hello"
icon = "star"
run = ["./view.sh"]
[[commands]]
id = "greet"
title = "Say hello"
context = "global"
run = ["./greet.sh"]sh
#!/bin/sh
# view.sh
cat > /dev/null # the request; this view ignores it
cat <<JSON
{"view": {"blocks": [
{"type": "heading", "text": "Hello from $(hostname)"},
{"type": "kv", "items": [{"label": "Runs as", "value": "$(id -un)"}]}
]}}
JSONsh
#!/bin/sh
# greet.sh
echo "{\"toast\": {\"message\": \"Hello from $(hostname)\"}}"Make both scripts executable, then run clusto plugin install ./hello.
Troubleshooting
- The Plugins section is missing - turn on
plugins.userin Settings > Labs. - Failed: the plugin's manifest differs from the approved one - the manifest changed after you approved it. Review and approve it again with Update.
- Failed: plugins need a run-as account on this node - the daemon runs as root and neither the plugin nor the node names an account. Set one at install time or set
default_session_useron that node. - A view says "view did not print a JSON reply" - run
clusto plugin view <id> <view>andclusto plugin logs <id>to see the output and stderr. - git asks for credentials - Clusto never prompts. Use a URL the run-as account can fetch without a prompt (a public URL, or SSH with that account's key).