Building a Plugin System for Your CLI Agent
How do you let people add tools to your agent without forking it? A cli agent plugin system lets users extend the agent with their own tools. Here's how to rebuild a hardcoded tool list into a real plugin system.
TOOLS = [read_file_tool, write_file_tool, shell_tool]
def get_tools():
return TOOLS # want a new tool? edit this fileHow do you let other people add tools to your agent without every one of them forking your code? It's the question that arrives the moment your CLI agent gets a second user who wants a capability you didn't build. The tempting answer — "they can just edit the tool list" — works for exactly one person and collapses into a mess of forks and merge conflicts the moment it's more. A real cli agent plugin system lets users and teammates extend the agent with new tools they own, without touching your core. This teardown takes the hardcoded approach everyone starts with and rebuilds it into something extensible.
The shift from a closed tool list to a plugin system is what turns a personal script into something a community or a team can build on together.
Before: The Hardcoded Tool List
Here's how nearly every agent starts — a fixed list of tools defined in the core code.
TOOLS = [read_file_tool, write_file_tool, shell_tool]
,[object Object], ,[object Object],():
,[object Object], TOOLS ,[object Object],What this does: Defines the agent's capabilities as a hardcoded list in the core module. Adding a tool means editing this file directly, which is fine for one developer working alone and breaks down the instant anyone else wants to extend the agent.
Why It Fails
The hardcoded list fails as soon as extension becomes a shared activity, and it fails through forking. When a teammate wants a new tool, their only option is to edit your core file — which means either sending you a change to merge or maintaining a fork. Multiply that by ten people who each want a different capability and you have ten forks, ten sets of merge conflicts, and no way to combine them.
It also couples every tool to your release cycle. A user who builds a useful tool can't share it without it landing in your codebase, so useful extensions either get abandoned or force you to become the bottleneck reviewing and merging everyone's additions. There's no way for capabilities to exist outside your core, which means there's no ecosystem — just your code and everyone's private edits to it.
And it makes the core fragile. Every merged tool is more surface area in the central codebase, more things that can break the agent for everyone. A cli agent plugin system inverts all of this: tools live outside the core, users add them without touching your code, and a broken plugin breaks only itself. The failure of the hardcoded approach is fundamentally that it has no boundary between "the agent" and "things the agent can do."
⚠️ Common mistake: Building extensibility by inviting people to edit the core tool list. It feels simple, but it makes every extension a fork, couples all tools to your release cycle, and turns you into the merge bottleneck for the whole ecosystem. Extension needs a boundary — a place tools can live outside the core and be loaded into it.
After: A Discovery-Based Plugin System
The rebuilt design defines a plugin contract — a small interface every plugin implements — and discovers plugins at runtime from a known location, loading whatever it finds. The core no longer knows about specific tools; it knows how to find and load things that follow the contract.
[object Object],
,[object Object], ,[object Object],:
name: ,[object Object],
version: ,[object Object],
,[object Object], ,[object Object],(,[object Object],) -> ,[object Object],: ,[object Object],
,[object Object], NotImplementedErrorWhat this does: Defines the stable interface a plugin must implement — a name, a version, and a register() method that returns its tools. Any plugin honoring this contract can be loaded by the core, and the core needs to know nothing about the plugin beyond the contract.
Discovery scans a plugins directory and loads each one dynamically, so dropping a plugin file into the folder is all it takes to extend the agent.
[object Object], importlib.util, pathlib
,[object Object], ,[object Object],():
plugin_dir = pathlib.Path.home() / ,[object Object], / ,[object Object],
plugin_dir.mkdir(parents=,[object Object],, exist_ok=,[object Object],)
tools = []
,[object Object], f ,[object Object], plugin_dir.glob(,[object Object],):
spec = importlib.util.spec_from_file_location(f.stem, f)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
,[object Object], ,[object Object],(module, ,[object Object],):
tools.extend(module.PLUGIN.register()) ,[object Object],
,[object Object], toolsWhat this does: Scans the plugins directory, imports each Python file, and calls register() on any that expose a PLUGIN following the contract, collecting their tools. A user extends the agent by adding a file to a folder — no core changes, no fork, no merge.
Breaking Down Each Element
The contract is what makes plugins interchangeable. Because every plugin implements the same small interface, the core can load any of them without special-casing — it calls register() and gets tools back, regardless of what the plugin does inside. A stable, minimal contract is the foundation everything else rests on; the smaller and more stable it is, the less likely a core change breaks existing plugins.
Discovery is what removes you as the bottleneck. When plugins load from a directory, anyone can add, share, or remove one independently. A teammate's plugin is a file they own and can send to a colleague; it never needs to enter your codebase. The agent gains capabilities without the core gaining code, which is exactly the decoupling the hardcoded version lacked.
The version field on the contract is the piece that keeps the system from rotting. When you evolve the plugin interface, the version tells the core whether a plugin was built for the current contract or an older one, so you can warn about or refuse incompatible plugins instead of crashing cryptically. Versioning the contract is how a plugin ecosystem survives the core changing over time.
⚡ Pro tip: Keep the plugin contract as small as you possibly can. Every method and field you require is something plugin authors must implement and something you can't change without breaking them. A minimal contract — ideally just "return your tools" — maximizes what plugins can do while minimizing what can break, which is the balance a healthy plugin system needs.
⚡ Pro tip: Load each plugin in a try/except and continue on failure. One broken plugin should disable itself with a logged warning, not crash the whole agent. Isolating plugin failures is what lets users run a dozen plugins without one buggy addition taking down everything — the resilience that makes an ecosystem tolerable.
How Do Plugins Avoid Colliding With Each Other?
A cli agent plugin system that loads a dozen plugins hits a problem the single-tool version never did: two plugins can define tools with the same name, or a plugin can shadow a core tool. Without a namespacing scheme, the model sees two search tools and can't tell them apart, and whichever loaded last silently wins. The fix is to namespace every plugin's tools by the plugin that provided them.
[object Object], ,[object Object],(,[object Object],):
tools = []
,[object Object], tool ,[object Object], plugin.register():
tool[,[object Object],] = ,[object Object], ,[object Object],
tools.append(tool)
,[object Object], tools
,[object Object],What this does: Prefixes each tool's name with its plugin's name, so two plugins can both offer a search without colliding and the model can distinguish github__search from docs__search. Namespacing is what lets many independent plugins coexist without stepping on each other.
The same discipline applies to configuration and state. A plugin that needs settings or storage should get its own namespaced slice — a config section and a data directory keyed by the plugin name — so plugins can't clobber each other's data or read settings that aren't theirs. Isolation by namespace is what keeps a growing set of plugins from turning into a shared-state mess.
⚡ Pro tip: Let users disable individual plugins without deleting them. A simple enabled/disabled list means someone can turn off a misbehaving plugin instantly instead of hunting for its file to remove. Toggling beats deleting when you just want to isolate whether a plugin is causing a problem.
⚡ Pro tip: Surface which plugin provided each tool in your help and logs. When the agent does something surprising, knowing that the tool came from the experimental-deploy plugin rather than the core points the user straight at the cause, instead of leaving them guessing whether the behavior is built in.
Variations for Different Contexts
Teams adapt the discovery mechanism to their distribution needs.
A platform team distributes internal plugins as installable packages using Python entry points instead of a directory scan, so plugins ship through their normal package registry and versioning comes for free from the package manager.
An open-source maintainer publishes a plugin contract and a template repo, letting the community build and share plugins as standalone packages, growing an ecosystem of capabilities the maintainer never has to write or merge.
A security-conscious enterprise adds a trust layer to discovery — plugins must be signed or allowlisted before they load — because loading arbitrary code from a directory is a real risk when plugins can come from anywhere.
That last case names the tradeoff every plugin system carries: a plugin is code running with your agent's privileges. Discovery convenience and security are in tension, and where you land depends on whether plugins come from trusted teammates or the open internet.
Save and Reuse This
The plugin contract and the discovery loader are the reusable heart of an extensible agent — get them right once and every future agent can share the same extension model, and even the same plugins. Redesigning the contract per project fragments your plugins so none of them transfer, which defeats the point of having a plugin system at all.
Keeping the plugin interface and loader alongside your prompts in a library like PromptABCD means your agents share one extension model, plugins written for one work in another, and adding a capability is always dropping a file in a folder — never forking the core and hoping the merge goes cleanly.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
