Tutorial: Plugin Development
On this page
This tutorial builds a small but complete plugin ecosystem covering all four plugin types, then wires
them into a PluginManager. Every API call below matches the actual signatures in the source — see the
API Overview for links to full reference pages.
1. Set up your project
my_app/
├── main.py
└── my_plugins/
├── search_plugin.py
├── summarize_plugin.py
└── log_action_plugin.py
PluginLoader._scan_directory globs *.py non-recursively and skips files starting with _, so keep
all your plugin files directly inside my_plugins/.
2. Build a RetrievalPlugin
my_plugins/search_plugin.py:
from zerda.plugins import RetrievalPlugin, PluginMetadata, SchemaProperty, ExecutionContext, CostTier
class SearchPlugin(RetrievalPlugin):
METADATA = PluginMetadata(
name="search",
version="1.0.0",
author="tutorial",
description="Searches a small in-memory document set.",
capabilities=["search", "retrieval"],
tags=["docs"],
use_cases=["Find relevant passages for a user question"],
cost_tier=CostTier.FREE,
input_schema=[
SchemaProperty(name="query", type="string", description="Search text"),
SchemaProperty(name="top_k", type="integer", description="Max results", required=False, default=3),
],
)
_DOCS = [
"RAG combines retrieval with generation.",
"Plugins extend an agent's capabilities.",
"Hot reload lets you update plugins without restarting.",
]
async def initialize(self) -> bool:
return True
async def execute(self, input_data, context: ExecutionContext):
q = input_data["query"].lower()
top_k = input_data.get("top_k", 3)
matches = [
{"content": d, "score": 1.0, "source": "tutorial-docs"}
for d in self._DOCS if q in d.lower()
]
return matches[:top_k]
async def cleanup(self) -> None:
pass
3. Build a ProcessingPlugin
my_plugins/summarize_plugin.py:
from zerda.plugins import ProcessingPlugin, PluginMetadata, ExecutionContext
class SummarizePlugin(ProcessingPlugin):
METADATA = PluginMetadata(
name="summarize",
version="1.0.0",
author="tutorial",
description="Joins retrieved passages into a single summary string.",
tags=["processing"],
)
async def initialize(self) -> bool:
return True
async def execute(self, input_data, context: ExecutionContext):
items = input_data.get("items", [])
texts = [i["content"] if isinstance(i, dict) else str(i) for i in items]
return [" ".join(texts)]
async def cleanup(self) -> None:
pass
4. Build an ActionPlugin
my_plugins/log_action_plugin.py:
from zerda.plugins import ActionPlugin, PluginMetadata, ExecutionContext
class LogActionPlugin(ActionPlugin):
METADATA = PluginMetadata(
name="log_result",
version="1.0.0",
author="tutorial",
description="Writes a result to the application log.",
tags=["action"],
)
REQUIRES_CONFIRMATION = False # safe to auto-run; the framework itself does not enforce this flag
async def can_execute(self, input_data, context: ExecutionContext) -> bool:
return "text" in input_data
async def initialize(self) -> bool:
return True
async def execute(self, input_data, context: ExecutionContext):
print(f"[LOG] {input_data['text']}")
return {"success": True, "message": "logged"}
async def cleanup(self) -> None:
pass
5. Wire it together
main.py:
import asyncio
from zerda.plugins import PluginManager, ExecutionContext
async def main():
manager = PluginManager(plugin_dirs=["./my_plugins"])
await manager.startup() # auto-discovers all three plugins above
print(manager.list_plugins())
result = await manager.execute_pipeline(
pipeline=["search", "summarize", "log_result"],
initial={"query": "plugins"},
)
print(result)
await manager.shutdown()
asyncio.run(main())
Walking through what happens:
searchreturns[{"content": "Plugins extend an agent's capabilities.", ...}].execute_pipelinefeeds that list directly asinput_datatosummarize— butsummarizeexpects{"items": [...]}. Since the previous plugin's output is alist, not adict,execute_pipeline'sdata if isinstance(data, dict) else {}guard meanssummarizeactually receives{}, not the search results. This is worth calling out explicitly: chaining aRetrievalPlugin(returnsList[Dict]) directly into aProcessingPlugin(expects{"items": [...]}) throughexecute_pipelinedoes not work out of the box, becauseexecute_pipelineonly passes dict outputs through unchanged.
[!TIP] Fixing the pipeline mismatch Either write a small adapter plugin between them that wraps a list into
{"items": [...]}, or don't useexecute_pipelinefor this case — callmanager.execute("search", ...)andmanager.execute("summarize", {"items": search_result})as two separateexecute()calls instead.
6. Expose the plugins to an LLM
tools = manager.anthropic_tools()
# [{"name": "search", "description": "...", "input_schema": {...}}, ...]
Pass tools directly as the tools parameter of an Anthropic Messages API call, or
manager.tool_descriptors() for the OpenAI equivalent.
7. Add permissions (optional, for safe_mode)
manager = PluginManager(plugin_dirs=["./my_plugins"], safe_mode=True)
await manager.startup() # does NOT auto-grant permissions when safe_mode=True
manager.grant_permission("log_result", "file_write")
In safe_mode=True, startup() skips the automatic security.grant_all_required() step, so any
permission a plugin declares in metadata.permissions must be granted explicitly before that plugin can
pass register()'s or execute()'s permission checks.
Next steps
- Cookbook: LLM Tool Integration
- Security — understand
InputSanitizer's injection-pattern checks before accepting untrusted input into yourinput_schemafields - Hot Reload — iterate on plugins without restarting your app