---
url: https://clusto.app/docs/plugins.md
description: >-
  Extend Clusto with your own plugins - sidebar views, event hooks, agent MCP
  tools and commands - installed from git or a folder.
---

# 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.

::: warning 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 deploys
```

In 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:

1. The plugin's run-as user for that node, set at install time (`--run-as`).
2. The node's `default_session_user`.
3. 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**:

* `global` and `node` commands are always offered.
* `project` commands are offered on a project's page and receive `project_id` and `dir` (the project folder).
* `session` commands are offered on a session's page and receive `session_id`, `project_id` and `dir`.

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](/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 = true
```

`search = 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)"}]}
]}}
JSON
```

```sh
#!/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.user` in 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_user` on that node.
* **A view says "view did not print a JSON reply"** - run `clusto plugin view <id> <view>` and `clusto 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).
