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"
}
FieldTypeDescription
namestringRequired. Plugin identifier (alphanumeric + hyphens)
versionstringSemver version (default: "0.1.0")
descriptionstringOne-line description
authorstringAuthor name
tagslist[string]Searchable tags
toolslist[string]Python module names that export TOOL_DEFS
commandslist[string]Python module names that export COMMAND_DEFS
skillslist[string]Relative paths to skill .md files
mcp_serversdictMCP server configs (see below)
dependencieslist[string]pip packages to auto-install
homepagestringURL 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 your input_schema
  • config — 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

FieldTypeDescription
namestrUnique tool name (PascalCase recommended)
schemadictJSON Schema with name, description, input_schema
funccallableHandler function (params, config) -> str
read_onlyboolTrue if the tool never modifies files/state
concurrent_safeboolTrue 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 Aliceargs = "Alice")
  • state — the AgentState object (messages, token counts, turn count)
  • config — the runtime config dict
  • Return True to 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:

ScopeDirectoryConfigUse case
User (default)~/.cheetahclaws/plugins/<name>/~/.cheetahclaws/plugins.jsonPersonal tools available everywhere
Project.cheetahclaws/plugins/<name>/.cheetahclaws/plugins.jsonProject-specific tools, committed to git
ExternalAny dir listed in $CHEETAHCLAWS_PLUGIN_PATHenable state in ~/.cheetahclaws/plugins.jsonShared 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 pull in the shared repo).
  • Malformed plugin.json files 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

  1. Push your plugin to a public git repo
  2. Users install with: /plugin install <name>@<git-url>
  3. Consider naming your repo cheetahclaws-<name> for discoverability

Checklist before publishing

  • plugin.json has accurate name, version, description
  • TOOL_DEFS list (not direct register_tool() calls)
  • Graceful degradation for optional dependencies
  • No hardcoded paths or API keys
  • README with install instructions and usage examples
  • Tested with cheetahclaws on Python 3.10+

Common Mistakes

MistakeFix
Calling register_tool() directlyExport TOOL_DEFS list instead — the loader registers for you
Importing cheetahclaws in plugin codeUse 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 pathsUse Path.home() / ".cheetahclaws" or relative paths

Reference