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.
# 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 loadedOr install from a git repo:
/plugin install my-plugin@https://github.com/you/cheetahclaws-my-pluginmy-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).
{
"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...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,
),
]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)
| 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 |
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 ...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": [],
},
}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
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"],
},
}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 userThe {args} placeholder is replaced with the user's input when the skill is invoked.
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>.
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 --projectExternal 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.
# 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-pluginsEach 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
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).
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.
/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.
# 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"""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- Push your plugin to a public git repo
- Users install with:
/plugin install <name>@<git-url> - Consider naming your repo
cheetahclaws-<name>for discoverability
-
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+
| 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 |
- CONTRIBUTING.md — project architecture and conventions
- Plugin loader source — how plugins are loaded
- ToolDef source — tool registration API
- Example plugin — minimal working template