Writing plugins
A plugin is a folder with a plugin file in it, and sometimes code and Claude extras. Say what should happen in the plugin file first, and reach for code only when the file can't say it.
Plugins come with the first ratchet release after 0.2.1.
Quick start
The Plugins page has a Make your own box with the path of the plugin guide that ships with ratchet, and Copy path. The easiest start is to ask Claude, for example make me a ratchet plugin that pings Slack when an agent finishes, and point it at that guide. A template folder, _template, sits next to the guide.
By hand:
- Copy the
_templatefolder and rename the copy, for example tomy-plugin. - In its
ratchet-plugin.json, setnameto the same name. - On the Plugins page, press Add plugin → Folder… and type the folder's full path.
- Open the plugin, press Review and allow, check the lines, and press Allow.
The template, file by file
ratchet-plugin.json:
{
"name": "my-plugin",
"version": "0.1.0",
"description": "One line an owner can read at a glance in the Plugins list",
"permissions": ["claude.extras", "events", "card.note", "code"],
"main": "index.mjs"
}
index.mjs:
export default function (api) {
api.on("agent.started", (event) => {
api.note(event.agent.id, "hello");
});
}
claude/.claude-plugin/plugin.json:
{
"name": "my-plugin-extras",
"description": "Claude skills, agents and hooks this plugin adds to every agent it applies to"
}
Once allowed, every new agent ratchet starts gets the note hello on its card, and the claude folder is loaded into it. Each permission is there for a reason: claude.extras because the claude folder exists, events for api.on, card.note for api.note, and code because the file sets main. Delete what you don't use.
Folder layout
my-plugin/
ratchet-plugin.json the plugin file (required)
index.mjs code, only when the plugin file sets "main"
claude/ Claude extras in Claude Code's own plugin format
.claude-plugin/
plugin.json
skills/ agents/ hooks/ commands/
README.md what the plugin does, for the person installing it
The name is 1 to 40 characters of lowercase letters, digits and dashes, starting with a letter or digit, for example slack-ping. It's the folder name under ~/.ratchet/plugins/, the settings key and the memory folder name. name in the plugin file must match that folder exactly. When you add a plugin from a folder, a git link or a pack, ratchet names the folder after name for you.
The plugin file
ratchet-plugin.json holds one JSON object:
| Field | Required | What it is |
|---|---|---|
name | Yes | Must equal the folder name. |
version | Yes | Any text, but use 1.2.3 form so updates work. See Versions and updates. |
description | Yes | One line, shown in the Plugins list. |
permissions | Yes | A list of permission names. No unknown names, no duplicates. |
settings | No | Fields the owner fills in, by key. |
launch | No | env and args: changes to how Claude starts. |
helper | No | A program ratchet runs while the plugin is on. |
reactions | No | What to do on events, with no code. |
main | No | A .mjs or .js file in the plugin folder: a relative path, no ... |
If the file is wrong, ratchet refuses it and names the field, for example launch.args: must be a non-empty array of strings.
settings
"settings": {
"port": { "type": "number", "label": "Proxy port", "default": 8787, "min": 1024, "max": 65535 },
"token": { "type": "string", "label": "API token", "sensitive": true, "required": true },
"telemetry": { "type": "boolean", "label": "Send usage statistics", "default": false }
}
typeisstring,numberorboolean, andlabelis what the owner sees. Both are required.defaultmust match the type.minandmaxlimit a number.sensitivevalues are stored but never shown again: the owner sees set.- A
requiredsetting with no value and no default makes new agents start without the plugin, with needs setting: <label> on their card.
Use a setting's value in launch.env, launch.args, helper.command, helper.env and helper.ready.url with ${settings.<key>}. An unknown key makes the launch skip the plugin, with unknown setting <key> on the card.
launch
"launch": {
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:${settings.port}",
"MY_FLAG": { "if": "telemetry", "then": "on", "else": "off" }
},
"args": ["--some-start-option"]
}
Launch changes apply to each new agent ratchet starts while the plugin is on and not off in that project. An env value is text, or { "if", "then", "else" } where if names a boolean setting. args are added to Claude's start options. Plugins apply in the owner's Order at launch; if two set the same env name, the later one wins and both rows show the clash.
Claude extras: the claude folder
A claude folder in Claude Code's own plugin format (skills, agents, hooks, commands) is loaded into each agent ratchet starts, with Claude Code's --plugin-dir option pointing at it. A skill shows up under the name from claude/.claude-plugin/plugin.json, for example waves-extras:wave-status. A plugin can be nothing but this:
{
"name": "waves",
"version": "0.1.0",
"description": "Wave skills for Claude",
"permissions": ["claude.extras"]
}
helper
From the Headroom plugin:
"helper": {
"command": ["headroom", "proxy", "--port", "${settings.port}"],
"env": { "HEADROOM_BEACON": { "if": "telemetry", "then": "on", "else": "off" } },
"ready": { "url": "http://localhost:${settings.port}/health" },
"requires": { "command": "headroom", "installHint": "uv tool install --python 3.13 \"headroom-ai[all]\"" }
}
- ratchet starts
commandwhen the plugin is on (at start-up, on Allow, when switched on) and stops it when the plugin is switched off or removed, or ratchet exits. requiresis checked first. Ifrequires.commandisn't on the PATH, the row showsinstallHintand nothing starts. ratchet never installs it.ready.urlmust be a plainhttp://address onlocalhost,127.0.0.1or[::1]. ratchet asks it every second, for up to a minute. Until it answers, new agents start without this plugin.envis added to ratchet's own environment for the helper. The helper's output goes to the plugin's log.- A helper that exits is restarted once. A second exit counts as a failure.
reactions
"reactions": [
{ "on": "agent.finished", "do": "alert", "text": "{agent} finished on {branch}" },
{ "on": "agent.started", "do": "note", "text": "watching {branch}" },
{ "on": "agent.finished", "do": "run", "command": ["git", "status", "--short"] },
{ "while": "agents.working", "do": "awake" }
]
onis an event:ratchet.started,ratchet.stopping,agent.started,agent.working,agent.question,agent.permissionoragent.finished.doisalertornote(withtext), orrun(withcommand, a list of words, no shell).- In
text,{agent}is the agent's label or name,{repo}is ratchet's id for the repo,{branch}its branch and{event}the event name. - A
noteneeds an agent, so it can't react toratchet.startedorratchet.stopping. - A
runcommand runs in the agent's folder, or in the plugin's memory folder when the event has no agent. Its output goes to the plugin's log. { "while": "agents.working", "do": "awake" }holds the PC awake while any agent is working.
Each reaction must finish within 5 seconds, or it counts as a failure. A command that can't start also counts. A command that ends with an error code does not.
Permissions
List every permission the plugin uses. ratchet works out what the plugin file needs and refuses a file that uses something it didn't ask for, for example uses run without asking for it. A plugin with code can list more than its file needs, because only the code knows what it will call.
| Permission | Needed for | Allow line |
|---|---|---|
launch.env | launch.env | Changes settings Claude starts with |
launch.args | launch.args | Adds Claude start options |
claude.extras | a claude folder | Adds Claude skills, agents and hooks to your agents |
helper.run | helper | Starts a program |
events | any on reaction, api.on, api.every | Reacts when agents start, wait or finish |
card.note | note, api.note | Shows notes on agent cards |
alert | alert, api.alert | Sends phone alerts |
awake | awake, api.awake | Keeps the PC awake |
run | run, api.run | Runs commands |
agent.start | api.agents.start | Starts new agents |
worktree | api.worktrees.start | Creates worktrees and branches |
ask | api.ask | Asks you to approve or reject steps |
code | main | Runs its own code — trust it like any program you install |
Some lines get more exact: launch.env that sets ANTHROPIC_BASE_URL reads Changes where Claude sends requests, and other env names are listed; launch.args and helper.run show the options and the command; a run reaction in a plugin without code shows its command.
Code plugins
Set main when the plugin file can't say what you need: reading a file an agent wrote, starting agents, asking the owner. The file's default export is a function. ratchet calls it once with api when the plugin loads: at start-up, on Allow, when it's switched on, and after an update.
It runs in ratchet's own process, with ratchet's rights on the owner's PC. The Allow dialog says so, and the plugin is marked has code.
When a plugin has main, events go to its code only: its on reactions are not carried out. A while awake reaction still is.
The api object
api only has the members its allowed permissions unlock. Without the permission, the member is missing: "alert" in api is false.
| Member | Needs | What it does |
|---|---|---|
api.name, api.dataDir, api.settings, api.log(line) | Always there | The plugin's name, its memory folder, its setting values, and a line for its log. |
api.on(type, handler) | events | Calls handler(event) for that event type. |
api.every(ms, name) | events | Sends a timer event with that name every ms, at most every 10 seconds. |
api.note(agentId, text) | card.note | One note per plugin per agent card. A newer one replaces the older. |
api.alert(text, agentId?) | alert | A phone alert, under the owner's alert rules. |
api.awake.hold(), api.awake.release() | awake | Holds the PC awake, or lets it go. Unloading the plugin lets it go. |
api.run(command, cwd, timeoutMs?) | run | Runs command (a list of words) in cwd, 60 seconds by default. Resolves to { code, stdout, stderr }. Output goes to the log. |
api.agents.start({ repoId, cwd, prompt, label }) | agent.start | Starts an agent with your label on its card. Resolves to the agent. |
api.worktrees.start({ repoId, branch, prompt, label }) | worktree | Makes a worktree and branch and starts a labelled agent in it. Resolves to the agent. |
api.ask({ agentId, question }) | ask | Asks the owner Approve or Reject on that agent. Resolves to the question's id. The answer comes as an ask.answered event. |
Events
| Event | Shape |
|---|---|
ratchet.started, ratchet.stopping | { type } |
agent.started, agent.working, agent.question, agent.permission, agent.finished | { type, agent: { id, repoId, name, label, plugin, cwd, branch, status } } |
timer | { type, name } |
ask.answered | { type, ask: { id, plugin, agentId, question, at, answer } }, where answer is "approve" or "reject" |
- Agent events are only for agents ratchet started.
labelandpluginarenullunless a plugin started the agent.statusisworking,waitingordone. - ratchet looks at its agents about every 10 seconds. An agent first seen already done still sends
agent.started, thenagent.finished. Opening a repo can send these once for its finished agents, so make handlers safe to run twice. - Each event reaches each plugin once. A slow plugin doesn't hold up other plugins or the agent.
- An answer given while the plugin wasn't loaded arrives the next time it loads, even after a restart.
The 5 second limit
Every call into a plugin (the load, each handler, each timer) is cut off after 5 seconds. A throw, a rejected promise and a timeout each count as one failure, and 3 in a row turn the plugin off. The work you started may carry on, but it still counts. So don't await slow work inside a handler: start it, and catch its errors.
api.on("agent.finished", (event) => {
api
.run(["npm", "test"], event.agent.cwd, 10 * 60 * 1000)
.then((result) => api.log("tests exited with " + result.code))
.catch((err) => api.log("tests did not run: " + err.message));
});
Example: plan, build, approve, merge
A code plugin that waits for a planning agent, starts a labelled lane agent in its own worktree, asks the owner before merging, and keeps its place in the memory folder so a restart doesn't lose it. Permissions: events, worktree, ask, agent.start and code.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export default function (api) {
const file = path.join(api.dataDir, "flow.json");
const load = () => (existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {});
const save = (flow) => writeFileSync(file, JSON.stringify(flow));
api.on("agent.finished", async (event) => {
const agent = event.agent;
const flow = load();
if (agent.plugin === null && !flow.started && existsSync(path.join(agent.cwd, "plan.md"))) {
save({ started: true, repoId: agent.repoId, mainCwd: agent.cwd });
api.worktrees
.start({ repoId: agent.repoId, branch: "lane-a", prompt: "Build step A of plan.md", label: "lane a" })
.catch((err) => api.log("lane a did not start: " + err.message));
return;
}
if (agent.label === "lane a" && !flow.askId) {
const askId = await api.ask({ agentId: agent.id, question: "Lane a is done. Merge it?" });
save({ ...flow, askId });
}
});
api.on("ask.answered", (event) => {
const flow = load();
if (event.ask.id !== flow.askId || event.ask.answer !== "approve") return;
api.agents
.start({ repoId: flow.repoId, cwd: flow.mainCwd, prompt: "Merge the lane-a branch", label: "merge" })
.catch((err) => api.log("merge did not start: " + err.message));
});
}
The lane agent's card reads lane a with the plugin's name, its worktree says it was made by the plugin, and the question shows on the lane agent with Reject and Approve.
The memory folder
api.dataDir is ~/.ratchet/plugin-data/<name>/. It survives ratchet restarts and plugin updates, and is deleted only when the owner removes the plugin. Keep your progress there, like flow.json above. A run reaction on an event without an agent runs there too.
Limits
- 5 seconds per call into a plugin; 3 failures in a row turn it off, with one phone alert. Any call that works resets the count.
- Timers fire at most every 10 seconds.
- A helper has a minute to answer its
ready.url, and is restarted once. - The log keeps the last 200 lines, in memory only.
- A pack fetched from a link can be at most 256 KB.
- A plugin can't answer an agent's question or permission prompt, and Away mode never answers a plugin's question.
- A plugin draws nothing of its own. Its settings, notes and Approve and Reject buttons are drawn by ratchet.
Versions and updates
ratchet offers an update when a git or pack plugin's link has a higher version in MAJOR.MINOR.PATCH form (a leading v is fine), for example 0.2.0 after 0.1.0. Raise version for every release you publish. A version that isn't in that form is never offered as an update, and pressing Update on the same version only says <name> is already at <version>.
ratchet compares the permission list of the new version with what the owner allowed:
- Same or fewer permissions: the update applies as soon as the owner presses Update to.
- Anything new: the old version keeps running, and the owner sees Update 0.2.0 asks for more with Review and allow 0.2.0 and Stay on 0.1.0.
The same rule covers a plugin edited in place: if its file starts asking for more, it stops acting until the owner allows it again.
Publish a plugin
As a git repo. Put ratchet-plugin.json at the top of the repo, one plugin per repo, and share the link. ratchet clones it with the owner's own git login, so a private repo works for anyone who can clone it.
As a pack. A pack is a JSON file that lists plugins by git link. Share it as a file or an https:// link:
{
"name": "team-pack",
"plugins": [
{ "git": "https://github.com/your-team/ratchet-headroom.git" },
{ "git": "git@github.com:your-team/slack-ping.git", "ref": "main" }
]
}
ref is an optional branch or tag. ratchet installs and checks updates from that ref, so a plugin pinned to a tag only moves when you change the pack. Each plugin in a pack asks for its own Allow. If one fails to install, the others still install and the error names the failing link.
Test it on your PC
- Add it with Add plugin → Folder…. That copies it, so to try a change either edit the installed copy in
~/.ratchet/plugins/<name>/, or remove the plugin and add it again. - ratchet reads the plugin file again whenever it lists plugins or starts an agent, so launch changes and reactions pick up edits. Switch the plugin off and on to reload its code and restart its helper.
- Check the Allow dialog: every line should be something you meant.
- Start an agent with New task and watch the plugin's Activity, the agent's card and Show log. Use
api.logto write your own lines. - Try the unhappy paths: a missing helper program, an empty required setting, a handler that throws. The agent should start anyway and the card should say why.
Examples
Stay awake, shipped with ratchet. No settings, no code:
{
"name": "stay-awake",
"version": "0.1.0",
"description": "Keeps the PC awake while any agent is working",
"permissions": ["awake"],
"reactions": [{ "while": "agents.working", "do": "awake" }]
}
Headroom, shipped with ratchet. A helper program and one environment setting:
{
"name": "headroom",
"version": "0.1.0",
"description": "Shrinks long sessions by sending Claude through the Headroom proxy",
"permissions": ["launch.env", "helper.run"],
"settings": {
"port": { "type": "number", "label": "Proxy port", "default": 8787, "min": 1024, "max": 65535 },
"telemetry": { "type": "boolean", "label": "Send usage statistics to Headroom", "default": false }
},
"launch": { "env": { "ANTHROPIC_BASE_URL": "http://localhost:${settings.port}" } },
"helper": {
"command": ["headroom", "proxy", "--port", "${settings.port}"],
"env": { "HEADROOM_BEACON": { "if": "telemetry", "then": "on", "else": "off" } },
"ready": { "url": "http://localhost:${settings.port}/health" },
"requires": { "command": "headroom", "installHint": "uv tool install --python 3.13 \"headroom-ai[all]\"" }
}
}
Done ping. A phone alert and a web hook call when an agent finishes, no code:
{
"name": "done-ping",
"version": "0.1.0",
"description": "Pings the team hook and your phone when an agent finishes",
"permissions": ["events", "alert", "run"],
"reactions": [
{ "on": "agent.finished", "do": "alert", "text": "{agent} finished on {branch}" },
{ "on": "agent.finished", "do": "run", "command": ["curl", "-s", "-d", "text=agent finished", "https://example.com/hooks/builds"] }
]
}
New to plugins as a user? Start with Using plugins.