Plugins and hooks
Cleetus is built around a small core that engines, tools, slash commands, panels and hooks plug into. Your own plugins use the same API as the built-in ones, and anything a plugin registers works for Claude and Codex alike.
Concepts
| Concept | What it is |
|---|---|
| Engine | An adapter that runs a coding agent and translates its protocol into Cleetus events |
| Session | One conversation with one engine: transcript, permissions and usage |
| Tool | A function you register. Every engine sees it on one local MCP server |
| Command | A slash command in the chat, such as /words |
| Panel | A small web page opened from the chat header |
| Hook | A function at a named point that can change or block behaviour |
The API is versioned. Plugins declare the major version they target (apiVersion, currently 1) and are rejected with a clear message if it doesn't match.
Write a plugin
A plugin is a folder in <profile>/plugins/<name>/ (by default ~/.cleetus/profile/plugins) with a manifest and an entry file. Third-party plugins start disabled; enable them in the Plugins view.
cleetus-plugin.json:
{
"id": "acme.wordcount",
"name": "Word count",
"version": "1.0.0",
"apiVersion": "^1",
"main": "index.cjs",
"description": "Adds a /words command and a word_count tool.",
"permissions": ["commands", "tools"]
}
index.cjs:
exports.activate = (api) => {
api.tools.register({
name: "word_count",
description: "Counts the words in a piece of text.",
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
handler: ({ text }) => String(text.split(/\s+/).filter(Boolean).length),
});
api.commands.register({
id: "words",
title: "Word count",
description: "Count words: /words some text",
run: (args) => `${args.split(/\s+/).filter(Boolean).length} words`,
});
};
exports.deactivate = () => {};
Permissions
| Permission | Grants |
|---|---|
engines |
api.engines.register(adapter) |
tools |
api.tools.register(definition) |
commands |
api.commands.register(definition) |
panels |
api.panels.register(definition), api.renderers.register(definition) |
hooks |
api.hooks.tap(name, fn) |
events |
api.events.on(type, fn) |
sessions |
api.sessions.list(), .get(id), .send(id, text) |
settings |
api.settings.register(definition) |
storage |
api.storage.get(key, fallback), .set(key, value), private to the plugin |
Everything a plugin registers is tracked under its ID and removed when it's disabled, and activation errors are caught and reported. Permissions gate the Cleetus API, not Node.js: a plugin runs in the host and can use any Node API, so only enable code you trust.
Hooks
Hooks run in priority order. Each gets a context object it can change; set stop = true to end the chain.
| Hook | Context | Use it to |
|---|---|---|
session.beforeSend |
{ sessionId, text, attachments, session, stop } |
Rewrite a prompt, add context or block it |
permission.decide |
{ sessionId, tool, input, decision, message } |
Answer a permission request with 'allow' or 'deny', without asking |
tool.beforeCall |
{ name, args, sessionId, owner, deny } |
Change a Cleetus tool's arguments, or set deny to a message to block it |
For example, a policy plugin that never lets agents read .env files:
// Tool input shapes are each engine's own; check the fields your engines send.
api.hooks.tap("permission.decide", (ctx) => {
const path = String(ctx.input?.file_path ?? ctx.input?.path ?? "");
if (/(^|\/)\.env(\.|$)/.test(path)) {
ctx.decision = "deny";
ctx.message = "Environment files are off limits.";
}
});
Events
api.events.on(type, fn) receives normalized events: session.created, session.state, session.settings, message.user, message.assistant, message.assistant.delta, message.thinking, tool.use, tool.result, permission.request, permission.resolved, usage.updated, turn.result, turn.complete, session.rewound and error. Use '*' for all of them. engine.raw carries each engine's untranslated message. Every event has schema, type, sessionId and ts.
Write an engine
An engine adapter is an object, registered with api.engines.register(adapter) from a plugin with the engines permission:
{
id: "myengine",
name: "My Engine",
apiVersion: 1,
capabilities: { models: true, efforts: ["low", "high"], permissionModes: ["default"], resume: true, usage: false, tools: true, images: false, rewind: false },
async probe() { return { available: true }; },
async createSession(ctx) { /* return an engine session */ },
}
ctx gives you cwd, options (model, effort, permissionMode, systemPromptAppend), resume, emit(type, fields), raw(message), requestPermission({ tool, input, title, description, reason }), onEngineSession(id), mcpServers() (including the Cleetus tools), addUsage(delta) and log(message).
The session you return implements send(prompt, { attachments, messageId }), interrupt() and close(), and optionally setModel, setEffort, setPermissionMode and rewind. Translate your engine's output into the events above with ctx.emit, and route every tool request through ctx.requestPermission so your engine gets the same approvals as Claude and Codex.