ratchet docs
Docs / Plugins / Writing plugins

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.

New

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:

  1. Copy the _template folder and rename the copy, for example to my-plugin.
  2. In its ratchet-plugin.json, set name to the same name.
  3. On the Plugins page, press Add plugin → Folder… and type the folder's full path.
  4. 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:

FieldRequiredWhat it is
nameYesMust equal the folder name.
versionYesAny text, but use 1.2.3 form so updates work. See Versions and updates.
descriptionYesOne line, shown in the Plugins list.
permissionsYesA list of permission names. No unknown names, no duplicates.
settingsNoFields the owner fills in, by key.
launchNoenv and args: changes to how Claude starts.
helperNoA program ratchet runs while the plugin is on.
reactionsNoWhat to do on events, with no code.
mainNoA .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 }
}
  • type is string, number or boolean, and label is what the owner sees. Both are required.
  • default must match the type. min and max limit a number.
  • sensitive values are stored but never shown again: the owner sees set.
  • A required setting 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 command when 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.
  • requires is checked first. If requires.command isn't on the PATH, the row shows installHint and nothing starts. ratchet never installs it.
  • ready.url must be a plain http:// address on localhost, 127.0.0.1 or [::1]. ratchet asks it every second, for up to a minute. Until it answers, new agents start without this plugin.
  • env is 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" }
]
  • on is an event: ratchet.started, ratchet.stopping, agent.started, agent.working, agent.question, agent.permission or agent.finished.
  • do is alert or note (with text), or run (with command, 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 note needs an agent, so it can't react to ratchet.started or ratchet.stopping.
  • A run command 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.
Keep reactions quick

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.

PermissionNeeded forAllow line
launch.envlaunch.envChanges settings Claude starts with
launch.argslaunch.argsAdds Claude start options
claude.extrasa claude folderAdds Claude skills, agents and hooks to your agents
helper.runhelperStarts a program
eventsany on reaction, api.on, api.everyReacts when agents start, wait or finish
card.notenote, api.noteShows notes on agent cards
alertalert, api.alertSends phone alerts
awakeawake, api.awakeKeeps the PC awake
runrun, api.runRuns commands
agent.startapi.agents.startStarts new agents
worktreeapi.worktrees.startCreates worktrees and branches
askapi.askAsks you to approve or reject steps
codemainRuns 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.

Code runs inside ratchet

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.

MemberNeedsWhat it does
api.name, api.dataDir, api.settings, api.log(line)Always thereThe plugin's name, its memory folder, its setting values, and a line for its log.
api.on(type, handler)eventsCalls handler(event) for that event type.
api.every(ms, name)eventsSends a timer event with that name every ms, at most every 10 seconds.
api.note(agentId, text)card.noteOne note per plugin per agent card. A newer one replaces the older.
api.alert(text, agentId?)alertA phone alert, under the owner's alert rules.
api.awake.hold(), api.awake.release()awakeHolds the PC awake, or lets it go. Unloading the plugin lets it go.
api.run(command, cwd, timeoutMs?)runRuns 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.startStarts an agent with your label on its card. Resolves to the agent.
api.worktrees.start({ repoId, branch, prompt, label })worktreeMakes a worktree and branch and starts a labelled agent in it. Resolves to the agent.
api.ask({ agentId, question })askAsks the owner Approve or Reject on that agent. Resolves to the question's id. The answer comes as an ask.answered event.

Events

EventShape
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. label and plugin are null unless a plugin started the agent. status is working, waiting or done.
  • ratchet looks at its agents about every 10 seconds. An agent first seen already done still sends agent.started, then agent.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": "[email protected]: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.log to 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.

Screenshots use example data. Written for ratchet 0.2.1.

ratchet is an independent project by Shervin Davarifard. Questions or problems: [email protected].© 2026 Shervin Davarifard · FSL-1.1-MIT licence