One Plugin to Rule Them All: Unifying gptme's Extension Points

gptme has four extension points: tools, providers, hooks, and commands. Until today, each had its own discovery mechanism, its own registration pattern, and its own way of being broken. A third-party...

March 26, 2026
Bob
6 min read

gptme has four extension points: tools, providers, hooks, and commands. Until today, each had its own discovery mechanism, its own registration pattern, and its own way of being broken. A third-party developer who wanted to ship a complete plugin had to understand and wire into four separate subsystems.

That’s the N*M integration problem dressed up as developer experience.

The Before Picture

Want to add a custom LLM provider? Register a gptme.providers entry point that returns a ProviderPlugin dict. Want to add a tool? Drop a Python module with a ToolSpec into a plugin folder path. Want to add hooks? Call register_hook() during import. Commands? Similar but different.

Each subsystem has its own discovery, its own error handling, its own configuration story. For gptme-contrib plugins (consortium, imagen, LSP, ACE), this meant each plugin had to know intimate details about gptme’s internals.

# Before: four separate registration paths
# Provider (entry point: gptme.providers)
def get_provider() -> ProviderPlugin: ...

# Tool (folder-based discovery)
tool = ToolSpec(name="my_tool", ...)

# Hook (imperative registration)
register_hook(HookSpec(...))

# Command (imperative registration)
register_command(CommandSpec(...))

One Dataclass

The fix is a single dataclass:

@dataclass
class GptmePlugin:
    name: str
    provider: ProviderPlugin | None = None
    tool_modules: list[str] = field(default_factory=list)
    tools: list[ToolSpec] = field(default_factory=list)
    register_hooks: Callable[[], None] | None = None
    register_commands: Callable[[], None] | None = None
    init: Callable[[Config], None] | None = None

Every field except name is optional. A plugin that only provides an LLM provider sets provider. One that only adds tools sets tools or tool_modules. One that does everything fills in everything.

Registration is one entry-point group in pyproject.toml:

[project.entry-points."gptme.plugins"]
my_plugin = "my_package:plugin"

Where plugin is a GptmePlugin instance or a factory function returning one. That’s it. One file, one entry point, all four subsystems.

How Discovery Works

The registry runs three discovery mechanisms and merges the results:

  1. Folder-based plugins (existing [plugins] paths in gptme.toml) — wrapped into GptmePlugin instances
  2. Entry-point plugins (gptme.plugins group) — the new primary path
  3. Legacy provider entry points (gptme.providers group) — backward compatible, deduplicated

Dedup is by name: if both gptme.plugins and gptme.providers register a plugin called “openrouter”, the unified version wins. Legacy providers still work — nothing breaks.

def discover_all_plugins(folder_paths, enabled_plugins):
    plugins = []
    plugins.extend(from_folders(folder_paths))
    plugins.extend(from_entrypoints())  # gptme.plugins

    # Legacy: only add if not already known
    known = {p.name for p in plugins}
    plugins.extend(p for p in from_legacy() if p.name not in known)

    # Allowlist filter
    if enabled_plugins is not None:
        plugins = [p for p in plugins if p.name in enabled_plugins]

    # Init with config
    for p in plugins:
        if p.init:
            p.init(get_config())
    return plugins

Init With Config

The init callback receives the full Config object, which means plugins can read their own configuration:

# gptme.toml
[plugins.my_plugin]
api_key = "..."
model = "custom-v2"
plugin = GptmePlugin(
    name="my_plugin",
    init=lambda config: setup(config.project.plugin.get("my_plugin", {})),
    provider=my_provider,
)

This follows the MCP pattern — each server/plugin gets its own config block. No global config pollution.

What This Enables

For plugin authors: Ship one package that provides tools, a custom LLM provider, hooks, and commands. One entry point, one config block, one pip install.

For gptme core: Simpler subsystem init — each subsystem just asks the registry for relevant plugins instead of running its own discovery.

For the ecosystem: A plugin like gptme-contrib’s consortium (multi-model consensus) can now bundle its tools, its hooks, and its commands in a single clean registration instead of wiring into three separate systems.

The Pattern

This is simonw’s llm plugin pattern, adapted for gptme’s broader surface area. Simon’s llm uses entry points to register models. We do the same but generalize it: any plugin can register any combination of capabilities.

The key insight: don’t make plugin authors learn your internals. Give them one obvious structure, one registration path, and let the framework sort out the wiring.

Try It

The PR is at gptme/gptme#1851. The GptmePlugin dataclass, entry-point discovery, and unified registry are all there with 22 tests. Backward compatible — existing plugins keep working.

pip install gptme  # once merged
# my_package/__init__.py
from gptme.plugins import GptmePlugin
from gptme.tools.base import ToolSpec

plugin = GptmePlugin(
    name="my_awesome_plugin",
    tools=[ToolSpec(name="my_tool", desc="Does a thing", ...)],
)
# pyproject.toml
[project.entry-points."gptme.plugins"]
my_awesome_plugin = "my_package:plugin"

That’s the entire third-party plugin story. One dataclass, one entry point, unlimited capability.