Plugin Authoring Guide
April 18, 2026 · View on GitHub
Build and distribute plugins for CheetahClaws. Plugins can add tools (callable by the AI), slash commands (typed by the user), skills (prompt templates), and MCP servers.
Quick Start
# Create a plugin from the example template
cp -r examples/example-plugin ~/.cheetahclaws/plugins/my-plugin
# Edit the files, then restart cheetahclaws
cheetahclaws
/plugin # verify it's loaded
Or install from a git repo:
/plugin install my-plugin@https://github.com/you/cheetahclaws-my-plugin
Plugin Structure
my-plugin/
├── plugin.json # manifest (required)
├── tools.py # tool definitions (optional)
├── cmd.py # slash commands (optional)
├── skills/ # skill markdown files (optional)
│ └── my-skill.md
└── README.md # documentation (optional)
The only required file is the manifest (plugin.json or PLUGIN.md).
Manifest: plugin.json
{
"name": "my-plugin",
"version": "0.1.0",
"description": "What this plugin does (shown in /plugin list)",
"author": "Your Name",
"tags": ["tag1", "tag2"],
"tools": ["tools"],
"commands": ["cmd"],
"skills": ["skills/my-skill.md"],
"mcp_servers": {},
"dependencies": ["some-pip-package>=1.0"],
"homepage": "https://github.com/you/cheetahclaws-my-plugin"
}
| Field | Type | Description |
|---|---|---|
name | string | Required. Plugin identifier (alphanumeric + hyphens) |
version | string | Semver version (default: "0.1.0") |
description | string | One-line description |
author | string | Author name |
tags | list[string] | Searchable tags |
tools | list[string] | Python module names that export TOOL_DEFS |
commands | list[string] | Python module names that export COMMAND_DEFS |
skills | list[string] | Relative paths to skill .md files |
mcp_servers | dict | MCP server configs (see below) |
dependencies | list[string] | pip packages to auto-install |
homepage | string | URL to the plugin's homepage/repo |
Alternative: PLUGIN.md — you can use YAML frontmatter instead of JSON:
---
name: my-plugin
version: 0.1.0
description: What this plugin does
tools:
- tools
commands:
- cmd
---
# My Plugin
Documentation goes here...
Adding Tools
Tools are functions the AI can call during a conversation. Create a tools.py that exports TOOL_DEFS:
"""my-plugin/tools.py"""
from tool_registry import ToolDef
def _my_tool(params: dict, config: dict) -> str:
"""Tool handler. Receives JSON params from the AI, returns a string result."""
query = params["query"]
# ... do something ...
return f"Result for: {query}"
def _my_readonly_tool(params: dict, config: dict) -> str:
"""A read-only tool that never modifies state."""
return "some information"
# This list is what the plugin loader reads.
# Do NOT call register_tool() directly — the loader handles registration.
TOOL_DEFS = [
ToolDef(
name="MyPluginSearch",
schema={
"name": "MyPluginSearch",
"description": "Search for something using my plugin.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query",
},
"limit": {
"type": "integer",
"description": "Max results (default: 5)",
"default": 5,
},
},
"required": ["query"],
},
},
func=_my_tool,
read_only=False,
concurrent_safe=True,
),
ToolDef(
name="MyPluginStatus",
schema={
"name": "MyPluginStatus",
"description": "Show plugin status information.",
"input_schema": {
"type": "object",
"properties": {},
},
},
func=_my_readonly_tool,
read_only=True,
concurrent_safe=True,
),
]
Tool handler contract
def my_handler(params: dict, config: dict) -> str:
params— the JSON parameters from the AI, validated against yourinput_schemaconfig— the runtime config dict (model, API keys, settings)- Return a string — this is what the AI sees as the tool result
- Output is auto-truncated to
max_tool_output(default 32KB)
ToolDef fields
| Field | Type | Description |
|---|---|---|
name | str | Unique tool name (PascalCase recommended) |
schema | dict | JSON Schema with name, description, input_schema |
func | callable | Handler function (params, config) -> str |
read_only | bool | True if the tool never modifies files/state |
concurrent_safe | bool | True if safe to run in parallel with other tools |
Graceful degradation
If your tool depends on an optional package, check at call time:
def _my_tool(params: dict, config: dict) -> str:
try:
import some_package
except ImportError:
return (
"some_package is not installed. Install it with:\n"
" pip install some_package"
)
# ... use some_package ...
Adding Slash Commands
Commands are typed by the user in the REPL (e.g., /mycommand args). Create a cmd.py that exports COMMAND_DEFS:
"""my-plugin/cmd.py"""
from ui.render import info, ok, err
def _cmd_greet(args: str, state, config) -> bool:
"""Handle /greet [name]"""
name = args.strip() or "world"
ok(f"Hello, {name}!")
return True
def _cmd_mystatus(args: str, state, config) -> bool:
"""Handle /mystatus"""
info(f"Messages: {len(state.messages)}")
info(f"Model: {config.get('model', '?')}")
return True
COMMAND_DEFS = {
"greet": {
"func": _cmd_greet,
"help": ("Say hello", []), # (description, [subcommands])
"aliases": ["hello", "hi"],
},
"mystatus": {
"func": _cmd_mystatus,
"help": ("Show plugin status", []),
"aliases": [],
},
}
Command handler contract
def my_command(args: str, state, config: dict) -> bool:
args— everything after the command name (e.g.,/greet Alice→args = "Alice")state— theAgentStateobject (messages, token counts, turn count)config— the runtime config dict- Return
Trueto stay in the REPL
Subcommands
For commands with subcommands (e.g., /myplugin setup, /myplugin status):
def _cmd_myplugin(args: str, state, config) -> bool:
parts = args.split() if args.strip() else []
sub = parts[0] if parts else ""
rest = " ".join(parts[1:])
if sub == "setup":
ok("Setting up...")
return True
elif sub == "status":
info("All good")
return True
else:
info("Usage: /myplugin <setup|status>")
return True
COMMAND_DEFS = {
"myplugin": {
"func": _cmd_myplugin,
"help": ("My plugin", ["setup", "status"]), # subcommands shown in Tab-complete
"aliases": ["mp"],
},
}
Adding Skills
Skills are Markdown prompt templates invoked via the Skill tool. Place .md files under a skills/ directory and list them in the manifest.
---
name: my-analysis
description: "Run a deep analysis on a codebase"
user-invocable: true
triggers: ["/my-analysis", "/analyze"]
tools: [Read, Glob, Grep]
---
# Analysis Skill
You are an expert code analyst. Perform a thorough analysis of the codebase.
## Steps
1. Use Glob to find all source files
2. Read the main entry point
3. Identify architectural patterns
4. Report findings in a structured format
## Arguments
- `{args}` — optional focus area provided by the user
The {args} placeholder is replaced with the user's input when the skill is invoked.
Adding MCP Servers
Bundle an MCP server with your plugin:
{
"mcp_servers": {
"myserver": {
"command": "python3",
"args": ["-m", "my_mcp_module"],
"env": {
"MY_CONFIG": "value"
}
}
}
}
The server name is auto-qualified as <plugin_name>__<server_name> to avoid collisions. Tools from the MCP server are registered as mcp__<plugin>__<server>__<tool>.
Installation Scopes
Plugins live in one of three scopes:
| Scope | Directory | Config | Use case |
|---|---|---|---|
| User (default) | ~/.cheetahclaws/plugins/<name>/ | ~/.cheetahclaws/plugins.json | Personal tools available everywhere |
| Project | .cheetahclaws/plugins/<name>/ | .cheetahclaws/plugins.json | Project-specific tools, committed to git |
| External | Any dir listed in $CHEETAHCLAWS_PLUGIN_PATH | enable state in ~/.cheetahclaws/plugins.json | Shared team/company plugins, no install step |
# Install to user scope (default)
/plugin install my-plugin@https://github.com/you/my-plugin
# Install to project scope
/plugin install my-plugin@./local/path --project
External Plugins (CHEETAHCLAWS_PLUGIN_PATH)
External plugins are discovered in-place from directories you control — CheetahClaws never copies them to ~/.cheetahclaws/plugins/. This is the right fit for shared team or company plugin directories: the ops team maintains one source of truth, users just point an env var at it.
Setup
# Single directory
export CHEETAHCLAWS_PLUGIN_PATH=/opt/company/cheetahclaws-plugins
# Multiple directories (colon-separated on Linux/macOS, semicolon on Windows)
export CHEETAHCLAWS_PLUGIN_PATH=/opt/company/plugins:$HOME/my-shared-plugins
Each immediate subdirectory with a plugin.json or PLUGIN.md is picked up:
/opt/company/cheetahclaws-plugins/
├── audit-tools/
│ ├── plugin.json
│ └── tools.py
├── company-skills/
│ ├── PLUGIN.md
│ └── skills/
└── .cache/ # hidden dirs are skipped
Default: disabled
External plugins start disabled. Run /plugin to see what was discovered:
Installed plugins (3):
git-helper [user] enabled Git convenience tools
audit-tools [external] disabled Compliance & audit helpers
company-skills [external] disabled Shared team prompts
Enable once — the decision persists to ~/.cheetahclaws/plugins.json and survives restarts:
/plugin enable audit-tools
If the plugin declares dependencies in its manifest, pip packages are installed at enable time (that's your informed-consent point — nothing auto-installs silently during normal use).
Name collisions
If the same plugin name exists in both installed (USER/PROJECT) and external scopes, the installed entry wins. Within external scopes, the earliest directory in CHEETAHCLAWS_PLUGIN_PATH wins — same semantics as $PATH.
Maintenance
/plugin uninstall <name>on an external plugin only drops CheetahClaws's enable-state record. It never deletes the source directory — that's the plugin author's to manage./plugin update <name>is refused for externals (update the source directory directly, e.g.git pullin the shared repo).- Malformed
plugin.jsonfiles are logged to stderr and skipped; one broken manifest in the path cannot crash/plugin.
Testing Your Plugin
Manual testing
# Copy to user plugins
cp -r my-plugin ~/.cheetahclaws/plugins/my-plugin
# Start CheetahClaws
cheetahclaws
# Verify
/plugin # should show your plugin
/greet World # test your command
Unit testing
"""tests/test_my_plugin.py"""
import pytest
from my_plugin.tools import TOOL_DEFS, _my_tool
def test_tool_defs_structure():
"""Verify TOOL_DEFS exports are valid."""
assert len(TOOL_DEFS) > 0
for tdef in TOOL_DEFS:
assert tdef.name
assert tdef.schema.get("name") == tdef.name
assert "input_schema" in tdef.schema
assert callable(tdef.func)
def test_my_tool_returns_string():
config = {"model": "test"}
result = _my_tool({"query": "hello"}, config)
assert isinstance(result, str)
assert "hello" in result
Publishing
- Push your plugin to a public git repo
- Users install with:
/plugin install <name>@<git-url> - Consider naming your repo
cheetahclaws-<name>for discoverability
Checklist before publishing
-
plugin.jsonhas accuratename,version,description -
TOOL_DEFSlist (not directregister_tool()calls) - Graceful degradation for optional dependencies
- No hardcoded paths or API keys
- README with install instructions and usage examples
- Tested with
cheetahclawson Python 3.10+
Common Mistakes
| Mistake | Fix |
|---|---|
Calling register_tool() directly | Export TOOL_DEFS list instead — the loader registers for you |
Importing cheetahclaws in plugin code | Use config parameter or import runtime for runtime state |
Assuming hooks exist (hook_session_start, etc.) | No event-based hooks — use tool/command handlers instead |
Putting runtime state in config["_xxx"] | Use runtime.get_ctx(config) for session state |
| Hardcoding file paths | Use Path.home() / ".cheetahclaws" or relative paths |
Reference
- CONTRIBUTING.md — project architecture and conventions
- Plugin loader source — how plugins are loaded
- ToolDef source — tool registration API
- Example plugin — minimal working template