Plugin System#

The plugin system allows extending gptme with custom tools, hooks, and commands without modifying the core codebase.

When to use plugins: For runtime integration (hooks, custom tools, commands). For lightweight knowledge bundles, see Lessons or Skills instead.

Plugin Structure#

A plugin is a Python package (directory with __init__.py) that can contain:

my_plugin/
├── __init__.py          # Plugin metadata
├── tools/               # Tool modules (optional)
│   ├── __init__.py     # Makes tools/ a package
│   └── my_tool.py      # Individual tool modules
├── hooks/               # Hook modules (optional)
│   ├── __init__.py     # Makes hooks/ a package
│   └── my_hook.py      # Individual hook modules
└── commands/            # Command modules (optional)
    ├── __init__.py     # Makes commands/ a package
    └── my_command.py   # Individual command modules

Configuration#

Plugins can be configured at two levels:

User-level (~/.config/gptme/config.toml): Applies to all projects.

Project-level (gptme.toml in workspace root): Applies only to this project, merged with user config.

[plugins]
# Paths to search for plugins (supports ~ expansion and relative paths)
paths = [
    "~/.config/gptme/plugins",
    "~/.local/share/gptme/plugins",
    "./plugins",  # Project-local plugins
]

# Optional: only enable specific plugins (empty = all discovered)
enabled = ["my_plugin", "another_plugin"]

Project-level plugin paths are relative to the workspace root.

Plugin-owned config lives under [plugin.<name>]. For example:

[plugin.my_plugin]
api_base = "https://example.com"
timeout = 30

Plugin init(config) hooks can read that config via config.project.plugin.get("my_plugin", {}) if config.project else {} and config.user.plugin.get("my_plugin", {}) if config.user else {}.

Skills vs Plugins#

Choose the right extensibility mechanism:

Need

Use

Why

Share knowledge and workflows

Skills

Lightweight bundles (Anthropic format)

Runtime hooks (lifecycle events)

Plugins

Deep integration with gptme runtime

Custom tools (new actions)

Plugins

Extend capabilities via Python code

Custom commands (/command)

Plugins

Add CLI commands for users

Bundled scripts (no integration)

Skills

Simple file bundles without hooks

Examples:

  • Skill: Python best practices guide with example scripts

  • Plugin: Automatic linting that runs hooks on file save

See Skills for lightweight knowledge bundles.

Creating a Plugin#

1. Create Plugin Directory Structure#

mkdir -p ~/.config/gptme/plugins/my_plugin/tools
touch ~/.config/gptme/plugins/my_plugin/__init__.py
touch ~/.config/gptme/plugins/my_plugin/tools/__init__.py

2. Create a Tool Module#

my_plugin/tools/hello.py:

from gptme.tools.base import ToolSpec

def hello_world():
    """Say hello to the world."""
    print("Hello from my plugin!")
    return "Hello, World!"

# Tool specification that gptme will discover
hello_tool = ToolSpec(
    name="hello",
    desc="Say hello",
    instructions="Use this tool to greet the world.",
    functions=[hello_world],
)

3. Use Your Plugin#

Start gptme and your plugin tools will be automatically discovered and available:

$ gptme "use the hello tool"
> Using tool: hello
Hello from my plugin!

How It Works#

  1. Discovery: gptme searches configured plugin paths for directories with __init__.py

  2. Loading: For each plugin, gptme discovers:

    • Tool modules in tools/ subdirectory

    • Hook modules in hooks/ subdirectory

    • Command modules in commands/ subdirectory

  3. Integration:

    • Plugin tools are loaded using the same mechanism as built-in tools

    • Plugin hooks are registered during initialization via their register() functions

    • Plugin commands are registered during initialization via their register() functions

  4. Availability:

    • Tools appear in --tools list and can be used like built-in tools

    • Hooks are automatically triggered at appropriate lifecycle points

    • Commands can be invoked with / prefix like built-in commands

Plugin Tool Modules#

Plugins can provide tools in two ways:

Option 1: tools/ as a Package#

Create tools/__init__.py and gptme will import my_plugin.tools as a package:

# my_plugin/tools/__init__.py
from gptme.tools.base import ToolSpec

tool1 = ToolSpec(...)
tool2 = ToolSpec(...)

Option 2: Individual Tool Files#

Skip tools/__init__.py and create individual files:

my_plugin/tools/
├── tool1.py
└── tool2.py

Each file will be imported as my_plugin.tools.tool1, my_plugin.tools.tool2, etc.

Plugin Hook Modules#

Plugins can provide hooks to extend gptme’s behavior at various lifecycle points, similar to how tools work.

Option 1: hooks/ as a Package#

Create hooks/__init__.py and define a register() function:

# my_plugin/hooks/__init__.py
from gptme.hooks import HookType, register_hook
from gptme.message import Message

def my_session_hook(logdir, workspace, initial_msgs):
    """Hook called at session start."""
    yield Message("system", f"Plugin initialized in workspace: {workspace}")

def register():
    """Register all hooks from this module."""
    register_hook(
        "my_plugin.session_start",
        HookType.SESSION_START,
        my_session_hook,
        priority=0
    )

Option 2: Individual Hook Files#

Create individual hook modules without hooks/__init__.py:

# my_plugin/hooks/logging_hook.py
from gptme.hooks import HookType, register_hook
from gptme.message import Message

def log_tool_execution(log, workspace, tool_use):
    """Log tool executions."""
    print(f"Executing tool: {tool_use.tool}")
    yield  # Hooks must be generators

def register():
    """Register hooks from this module."""
    register_hook(
        "my_plugin.log_tool",
        HookType.TOOL_EXECUTE_PRE,
        log_tool_execution,
        priority=0
    )

Hook Types#

Available hook types:

  • SESSION_START - Called at session start

  • SESSION_END - Called at session end

  • TOOL_EXECUTE_PRE - Before tool execution

  • TOOL_EXECUTE_POST - After tool execution

  • TOOL_CONFIRM - Blocking confirmation/deny decision before execution

  • FILE_SAVE_PRE - Before saving a file

  • FILE_SAVE_POST - After saving a file

  • GENERATION_PRE - Before generating response

  • GENERATION_POST - After generating response

  • And more (see gptme.hooks.HookType)

Hook Registration#

Every hook module must have a register() function that calls register_hook() for each hook it provides. The plugin system automatically calls register() during initialization.

Plugin Command Modules#

Plugins can provide custom commands that users can invoke with the / prefix, similar to built-in commands like /help or /exit.

Option 1: commands/ as a Package#

Create commands/__init__.py and define a register() function:

# my_plugin/commands/__init__.py
from gptme.commands import register_command, CommandContext
from gptme.message import Message

def weather_handler(ctx: CommandContext):
    """Handle the /weather command."""
    location = ctx.full_args or "Stockholm"
    # Your weather logic here
    yield Message("system", f"Weather in {location}: Sunny, 20°C")

def register():
    """Register all commands from this module."""
    register_command("weather", weather_handler, aliases=["w"])

Option 2: Individual Command Files#

Create individual command modules without commands/__init__.py:

# my_plugin/commands/joke.py
from gptme.commands import register_command, CommandContext
from gptme.message import Message

def joke_handler(ctx: CommandContext):
    """Tell a random joke."""
    jokes = [
        "Why did the AI cross the road? To optimize the other side!",
        "What's an AI's favorite snack? Microchips!",
    ]
    import random
    yield Message("system", random.choice(jokes))

def register():
    """Register command."""
    register_command("joke", joke_handler, aliases=["j"])

Using Plugin Commands#

Once registered, commands can be used like built-in commands:

$ gptme
> /weather London
Weather in London: Sunny, 20°C

> /joke
Why did the AI cross the road? To optimize the other side!

Command Handler Requirements#

Command handlers must:

  1. Accept a CommandContext parameter with:

    • args: List of space-separated arguments

    • full_args: Complete argument string

    • manager: LogManager instance

    • confirm: Confirmation function

  2. Be a generator (use yield) that yields Message objects

  3. Be registered via register_command() in a register() function

Example: Logging Plugin#

A complete example of a plugin that logs tool executions:

# my_logging_plugin/hooks/tool_logger.py
from gptme.hooks import HookType, register_hook
import logging

logger = logging.getLogger(__name__)

def log_tool_pre(log, workspace, tool_use):
    """Log before tool execution."""
    logger.info(f"Executing tool: {tool_use.tool} with args: {tool_use.args}")
    yield  # Hooks must be generators

def log_tool_post(log, workspace, tool_use, result):
    """Log after tool execution."""
    logger.info(f"Tool {tool_use.tool} completed")
    yield

def register():
    register_hook("tool_logger.pre", HookType.TOOL_EXECUTE_PRE, log_tool_pre)
    register_hook("tool_logger.post", HookType.TOOL_EXECUTE_POST, log_tool_post)

Example: Guardrail Plugin#

A complete example of a plugin that blocks dangerous shell commands using the TOOL_CONFIRM hook. TOOL_CONFIRM is the canonical hook for deny decisions — returning ConfirmationResult.skip() prevents the tool from executing at all. This is the right hook for deterministic, below-the-model guardrails.

# my_guardrail/hooks/shell_guard.py
import re
from gptme.hooks import HookType, register_hook
from gptme.hooks.confirm import ConfirmationResult
import logging

logger = logging.getLogger(__name__)

# Patterns to block regardless of prompt justification
_DESTRUCTIVE = re.compile(
    r"rm\s+-[a-z]*(?:rf|fr)[a-z]*|"  # rm -rf/-fr/-Rf/-RF variants
    r"DROP\s+TABLE|"            # SQL destructive
    r"chmod\s+-R\s+0{3}|"      # remove all permissions
    r":\s*\(\s*\)\s*\{.*\}\s*;" # fork bomb
    , re.IGNORECASE
)

def shell_guard(tool_use, preview=None, workspace=None):
    """Block destructive shell commands before execution."""
    if tool_use.tool != "shell":
        return None  # Not a shell tool — pass to next hook

    # Shell commands are passed as content, not args
    command = tool_use.content or ""
    if _DESTRUCTIVE.search(command):
        logger.warning("Guardrail blocked: %s", command)
        return ConfirmationResult.skip(
            f"Blocked by guardrail: destructive pattern detected in: {command!r}"
        )

    return None  # Not blocked — pass to next hook (e.g. user confirmation)

def register():
    register_hook(
        "my_guardrail.shell_guard",
        HookType.TOOL_CONFIRM,
        shell_guard,
        priority=200,  # Must be > 100 to run before server_confirm
    )

TOOL_CONFIRM hooks receive (tool_use, preview, workspace) and should return one of:

  • ConfirmationResult.skip(reason) — deny execution (blocks the tool call)

  • None — no opinion; pass to the next hook (e.g. the interactive user prompt)

  • ConfirmationResult.confirm() — explicitly auto-confirm without prompting

A guardrail hook should return None for operations it does not want to block so that lower-priority hooks — including the normal user confirmation prompt — can still run. Returning confirm() from a high-priority hook bypasses those prompts entirely. The hook is blocking — gptme waits for the result before proceeding.

Example: Opt-in HOL Guard Shell Classification#

Users who already run HOL Guard can classify shell command text before gptme continues through its configured confirmation hooks. This is an opt-in documentation example, not a gptme built-in: copy it into a plugin only if you already use Guard. It does not auto-approve commands, change gptme’s core, or protect non-shell tools.

The example invokes the official CLI as an argv list (no shell):

hol-guard command test <command> --json

command test reports policy_evaluation: "not_run". That is command classification, not final policy enforcement, approval creation, receipts, or protection of other tools. The CLI can attempt daemon startup as a side effect of inspection.

Behavior:

  • Non-shell tools are left unchanged (the hook returns None immediately).

  • For the shell tool, the hook returns None only for an explicitly benign classification whose minimum_action is "allow", so remaining confirmation hooks still run.

  • Any other classification, timeout, missing executable, or invalid output returns ConfirmationResult.skip() and the shell action does not run.

Register above every built-in TOOL_CONFIRM hook that can return a non-None result (see Confirmation Hooks and Deny Decisions). The example uses priority 250, which out-ranks guardrails (200), anomaly_watchdog.confirm (150), watch.sleep_poll_guard (150), server_confirm / tui_confirm (100), shell.allowlist / watch.allowlist (10), and cli_confirm / auto_confirm (0). Verify those priorities against the built-ins in your gptme version rather than assuming the hook runs first.

The tested copy of this example is tests/fixtures/hol_guard_shell_hook.py.

"""Opt-in HOL Guard shell-classification hook for gptme ``TOOL_CONFIRM``.

Copy this module into a plugin (for example ``hol_guard/hooks/shell_classify.py``)
and call ``register()``. gptme does not bundle or enable HOL Guard.

This hook classifies shell command text with the official CLI::

    hol-guard command test <command> --json

It does not auto-approve commands. An explicitly benign classification with
``minimum_action == "allow"`` returns ``None`` so remaining confirmation hooks
still run. Every other classification, timeout, missing executable, or invalid
output skips the shell action.

This CLI reports ``policy_evaluation: "not_run"``: it is command classification,
not final policy enforcement, approval creation, receipts, or protection of
other tools. The CLI can attempt daemon startup as a side effect of inspection.
"""

from __future__ import annotations

import json
import logging
import shutil
import subprocess
from typing import TYPE_CHECKING

from gptme.hooks import HookType, register_hook
from gptme.hooks.confirm import ConfirmationResult

if TYPE_CHECKING:
    from pathlib import Path

    from gptme.tools.base import ToolUse

logger = logging.getLogger(__name__)

HOOK_NAME = "hol_guard.shell_classify"
# Out-rank every built-in TOOL_CONFIRM hook. Current built-ins:
#   guardrails                 200
#   anomaly_watchdog.confirm   150
#   watch.sleep_poll_guard     150
#   server_confirm / tui_confirm 100
#   shell.allowlist / watch.allowlist 10  (these auto-confirm)
#   cli_confirm / auto_confirm 0
# Returning None for a benign classification still lets those hooks run.
HOOK_PRIORITY = 250
HOL_GUARD_EXECUTABLE = "hol-guard"
TIMEOUT_SECONDS = 10.0


def _shell_command(tool_use: ToolUse, preview: str | None) -> str:
    """Return the command text gptme would confirm for this shell call."""
    if preview and preview.strip():
        return preview.strip()
    content = getattr(tool_use, "preview_content", None)
    if isinstance(content, str) and content.strip():
        return content.strip()
    return (getattr(tool_use, "content", None) or "").strip()


def _skip(reason: str) -> ConfirmationResult:
    logger.warning("HOL Guard skipped shell command: %s", reason)
    return ConfirmationResult.skip(reason)


def hol_guard_shell_hook(
    tool_use: ToolUse,
    preview: str | None = None,
    workspace: Path | None = None,
) -> ConfirmationResult | None:
    """Classify shell commands; leave every other tool unchanged."""
    if tool_use.tool != "shell":
        return None

    command = _shell_command(tool_use, preview)
    if not command:
        return _skip("HOL Guard: shell command text is missing or invalid.")

    executable = shutil.which(HOL_GUARD_EXECUTABLE)
    if executable is None:
        return _skip("HOL Guard: hol-guard executable was not found.")

    try:
        completed = subprocess.run(
            [executable, "command", "test", command, "--json"],
            capture_output=True,
            text=True,
            timeout=TIMEOUT_SECONDS,
            check=False,
            shell=False,
        )
    except subprocess.TimeoutExpired:
        return _skip("HOL Guard: command inspection timed out.")
    except OSError:
        return _skip("HOL Guard: command inspection was unavailable.")
    except UnicodeDecodeError:
        return _skip("HOL Guard: command inspection returned invalid output.")

    if completed.returncode != 0:
        return _skip("HOL Guard: command inspection failed.")

    try:
        result = json.loads(completed.stdout)
    except json.JSONDecodeError:
        return _skip("HOL Guard: command inspection returned invalid JSON.")

    if not isinstance(result, dict):
        return _skip("HOL Guard: command inspection returned invalid output.")

    classification = result.get("classification")
    explicitly_benign = (
        isinstance(classification, dict)
        and classification.get("explicitly_benign") is True
    )
    if explicitly_benign and result.get("minimum_action") == "allow":
        # Fall through so cli/server/allowlist confirmation hooks still run.
        return None

    return _skip("HOL Guard: command requires review before execution.")


def register() -> None:
    """Register the opt-in HOL Guard shell-classification hook."""
    register_hook(
        HOOK_NAME,
        HookType.TOOL_CONFIRM,
        hol_guard_shell_hook,
        priority=HOOK_PRIORITY,
    )

Example: Weather Plugin#

A complete example of a weather information plugin:

my_weather/tools/weather.py:

from gptme.tools.base import ToolSpec, ToolUse
import requests

def get_weather(location: str) -> str:
    """Get weather for a location."""
    # Implementation
    return f"Weather in {location}: Sunny, 72°F"

weather_tool = ToolSpec(
    name="weather",
    desc="Get current weather information",
    instructions="Use this tool to get weather for a location.",
    functions=[get_weather],
)

Configuration (~/.config/gptme/config.toml):

[plugins]
paths = ["~/.config/gptme/plugins"]

Usage:

$ gptme "what's the weather in San Francisco?"
> Using tool: weather
Weather in San Francisco: Sunny, 72°F

Distribution#

Plugins can be distributed as:

  1. Git repositories: Clone into plugin directory

    git clone https://github.com/user/gptme-plugin ~/.config/gptme/plugins/plugin-name
    
  2. PyPI packages: Install and add to plugin path

    pip install gptme-weather-plugin
    # Add site-packages location to plugins.paths in gptme.toml
    
  3. Local directories: Copy plugin folder to plugin path

    cp -r my_plugin ~/.config/gptme/plugins/
    

Plugin Registry#

Community plugins, skills, and MCP servers are discovered through GitHub topics. The live catalog is gptme-contrib — see its Community Extensions dashboard.

To get listed:

  1. Add the appropriate GitHub topic to your repository:

    • gptme-plugin — Python plugin packages (tools, hooks, commands)

    • gptme-skill — SKILL.md skill bundles

    • gptme-mcp-server — MCP servers designed for or compatible with gptme

    On GitHub: open your repository → click the ⚙ gear icon next to “About” → add topics. From the CLI:

    gh repo edit owner/your-repo --add-topic gptme-plugin
    
  2. Browse existing work on the topic pages (gptme-plugin, gptme-skill, gptme-mcp-server). A curated skill starting set is in the Skills Gallery.

Unified Plugin System (Entry Points)#

For pip install-able packages, gptme supports a unified plugin interface via Python entry points. A single GptmePlugin dataclass lets a package expose any combination of tools, hooks, commands, and LLM providers — no folder structure required.

This is the recommended approach for published packages. It uses the same mechanism as pytest plugins, setuptools console_scripts, etc.

GptmePlugin Dataclass

from gptme.plugins.plugin import GptmePlugin

GptmePlugin(
    name="my_plugin",           # Unique plugin name
    provider=None,              # ProviderPlugin for LLM providers (optional)
    tool_modules=["..."],       # Module names containing ToolSpec instances (optional)
    tools=[...],                # Direct ToolSpec instances (optional)
    register_hooks=fn,          # Callable that registers hooks (optional)
    register_commands=fn,       # Callable that registers commands (optional)
    init=fn,                    # Callable(Config) for plugin initialization (optional)
)

A plugin only needs to provide the capabilities it uses — all fields except name are optional.

Creating a Unified Plugin

  1. Create a Python package with a GptmePlugin instance:

# src/my_gptme_plugin/__init__.py
from gptme.plugins.plugin import GptmePlugin
from gptme.tools.base import ToolSpec

def greet(name: str = "World") -> str:
    """Greet someone."""
    return f"Hello, {name}!"

greet_tool = ToolSpec(
    name="greet",
    desc="Greet someone by name",
    instructions="Use this tool to greet people.",
    functions=[greet],
)

plugin = GptmePlugin(
    name="my_gptme_plugin",
    tools=[greet_tool],
)
  1. Register the entry point in pyproject.toml:

[project]
name = "my-gptme-plugin"
version = "0.1.0"
dependencies = ["gptme"]

[project.entry-points."gptme.plugins"]
my_gptme_plugin = "my_gptme_plugin:plugin"
  1. Install and use:

pip install my-gptme-plugin
gptme "use the greet tool to say hello to Alice"

Full-Featured Plugin Example

A plugin that provides a tool, a hook, a command, and an LLM provider:

# src/my_full_plugin/__init__.py
from gptme.plugins.plugin import GptmePlugin
from gptme.tools.base import ToolSpec
from gptme.llm.models import ModelMeta, ProviderPlugin

# Tool
def lookup(query: str) -> str:
    """Look up information."""
    return f"Result for: {query}"

lookup_tool = ToolSpec(
    name="lookup",
    desc="Look up information",
    instructions="Use this tool to search for information.",
    functions=[lookup],
)

# Hook registration
def _register_hooks():
    from gptme.hooks import HookType, register_hook
    from gptme.message import Message

    def on_session_start(logdir, workspace, initial_msgs):
        yield Message("system", "My plugin initialized!")

    register_hook("my_plugin.start", HookType.SESSION_START, on_session_start)

# Command registration
def _register_commands():
    from gptme.commands import register_command, CommandContext
    from gptme.message import Message

    def status_handler(ctx: CommandContext):
        yield Message("system", "My plugin is running!")

    register_command("my-status", status_handler)

# LLM provider (optional)
my_provider = ProviderPlugin(
    name="my_llm",
    base_url="https://api.example.com/v1",
    api_key_env="MY_LLM_API_KEY",
    models=[ModelMeta(provider="unknown", model="my_llm/fast", context=128_000)],
)

# Keyless provider — omit api_key_env for payment-based or custom-auth endpoints
# my_provider = ProviderPlugin(
#     name="my_llm",
#     base_url="https://api.example.com/v1",
#     # api_key_env omitted — this provider authenticates per-request
#     models=[...],
# )

# Plugin initialization (optional)
def _init(config):
    """Called once at startup with the full gptme Config."""
    # Access plugin-specific config via:
    #   config.user.plugin.get("my_full_plugin", {})
    pass

plugin = GptmePlugin(
    name="my_full_plugin",
    tools=[lookup_tool],
    register_hooks=_register_hooks,
    register_commands=_register_commands,
    provider=my_provider,
    init=_init,
)

How Discovery Works

The plugin registry merges three sources in order:

  1. Folder plugins — from paths configured in gptme.toml

  2. Entry-point plugins — from the gptme.plugins group (deduped against folder plugins)

  3. Legacy provider plugins — from the gptme.providers group (backward compat)

If the same plugin name appears in multiple sources, folder plugins take precedence over entry-point plugins (to support editable installs where both are present). The plugins.enabled allowlist in gptme.toml can filter which plugins are loaded.

Factory Functions

Entry points can reference either a GptmePlugin instance directly, or a callable (factory) that returns one:

[project.entry-points."gptme.plugins"]
# Direct instance
my_plugin = "my_package:plugin"
# Factory function
my_plugin = "my_package:create_plugin"

Legacy Provider Entry Points

The older gptme.providers entry-point group still works for provider-only plugins. For new plugins, prefer gptme.plugins with a provider field — it supports all plugin capabilities in a single registration. See Provider Integration Guide for details.

Migration from TOOL_MODULES#

The plugin system is compatible with the existing TOOL_MODULES environment variable.

Old approach:

export TOOL_MODULES="gptme.tools,my_custom_tools"
gptme

New approach (gptme.toml):

[plugins]
paths = ["~/.config/gptme/plugins"]
enabled = ["my_plugin"]

Both approaches work and can coexist. The plugin system provides better organization and discoverability for complex tool collections.

Troubleshooting#

Plugin not discovered:

  • Ensure plugin directory has __init__.py

  • Check plugin path is correctly configured in gptme.toml

  • Verify path is absolute or relative to config directory

  • For entry-point plugins: verify the package is installed (pip list | grep my-plugin)

Tools not loading:

  • Check tools/ directory exists and has proper structure (for folder plugins)

  • Verify tool modules define ToolSpec instances

  • Look for import errors in gptme logs (gptme -v)

Plugin not enabled:

  • If using plugins.enabled allowlist, ensure plugin name is included

  • Remove enabled list to load all discovered plugins

Entry-point plugin not found:

  • Verify the entry point is declared in pyproject.toml under [project.entry-points."gptme.plugins"]

  • Reinstall the package (pip install -e .) after adding or changing entry points

  • Check that the exported object is a GptmePlugin instance or a callable returning one