Extension Patterns
The Plugin System (RFC-600) provides a decorator-based API for extending Soothe with custom tools, subagents, and event types. This guide covers the design decisions, patterns, and gotchas for building extensions that integrate cleanly with Soothe’s architecture.
→ SDK Source: packages/soothe-sdk/src/soothe_sdk/plugin/
Design Philosophy
Soothe’s extension model is built on three principles:
- Decorator-based, not inheritance-heavy — plugins are plain Python classes decorated with
@plugin, with@tool/@subagentmethods. No deep class hierarchies to learn. The decorators attach aPluginManifestto the class and add helper methods for extracting tools/subagents. - Manifest as single source of truth — the
@plugindecorator’s arguments become aPluginManifest(Pydantic model withextra="forbid") that carries all metadata: name, version, dependencies, trust level, config requirements. This is the contract between plugin and loader. - Graceful degradation — a failing plugin disables itself, not the orchestrator. Missing dependencies prevent loading but don’t crash the daemon. This is critical for a system that loads many plugins at startup.
Extension Points
| Extension Point | Decorator | Produces |
|---|---|---|
| Tools | @tool |
BaseTool or callable registered for agent use |
| Subagents | @subagent |
Factory returning a CompiledSubAgent runnable |
| Tool groups | @tool_group |
Class of related tools registered as a set |
| Events | register_event() |
Wire event class registration for observability |
Trust Levels and Security Boundaries
Plugins declare a trust_level that determines their permission envelope:
| Level | Use Case | Permissions |
|---|---|---|
built-in |
Core capabilities (planner, deep_research, academic_research, browser_use, veritas) | Full — can request any permission |
trusted |
Verified third-party plugins | Elevated |
standard |
Most third-party plugins (default) | Default permissions |
untrusted |
Experimental/plugins under review | Restricted |
Built-in plugins cannot be overridden by lower-trust plugins — the discovery priority system enforces this. Trust levels are not advisory; they’re enforced by PolicyProtocol when the plugin requests permissions at runtime.
Plugin Lifecycle
A plugin’s lifecycle has three hooks:
on_load(context)— called when the plugin is loaded. Use for: initializing resources, validating configuration, setting up connections. Thecontextprovides config, a logger, runtime services (policy, persistence, vector_store), and anemit_eventcallable.on_unload()— called when the plugin is unloaded. Use for: closing connections, saving state, releasing resources.health_check()— called periodically. Returns aPluginHealthwith status and message. The daemon uses this to monitor plugin health.
The services dict in context is populated by the daemon during loading — it includes "policy" (PolicyProtocol), "persistence", "emit_progress", and "vector_store". Plugins access these via context.services["policy"] rather than importing daemon internals directly.
Discovery Mechanisms and Priority
Soothe discovers plugins through three mechanisms, resolved in priority order:
| Priority | Mechanism | Best For |
|---|---|---|
| 100 | Built-in (always wins, cannot be overridden) | Core capabilities |
| 50 | Entry points ([project.entry-points."soothe.plugins"] in pyproject.toml) |
Distributable packages |
| 30 | Config-declared (plugins: in config.yml with module: "path:Class") |
Runtime/environment-specific plugins |
| 10 | Filesystem (~/.soothe/plugins/<name>/plugin.py) |
User-specific, quick experimentation |
Entry points are recommended for distributable plugins — they use standard Python packaging and are discovered via importlib.metadata. Config-declared plugins need no installation, making them ideal for environment-specific or development setups. Filesystem plugins are lowest priority but require zero setup.
When the same plugin name is found by multiple mechanisms, the highest priority wins. Built-in plugins are untouchable.
Dependency Management
Plugins declare two types of dependencies in the manifest:
- Library dependencies (
dependencies): PEP 440 version specifiers (e.g.,"arxiv>=2.0.0"). Missing dependencies prevent plugin loading — the orchestrator logs the error and continues with other plugins. - Configuration dependencies (
config_requirements): dotted paths to required config values (e.g.,"providers.openai.api_key"). These are validated at load time.
For optional dependencies (features that work with or without a library), use try/except ImportError in on_load() and degrade gracefully — log a warning and set a flag. The plugin loads either way, and tools can check the flag at call time.
Minimal Plugin Example
from soothe_sdk.plugin import plugin, tool
@plugin(name="my-tools", version="1.0.0", trust_level="standard")
class MyToolsPlugin:
async def on_load(self, context):
self.api_key = context.config.get("api_key")
context.logger.info("Loaded my-tools v1.0.0")
@tool(name="greet", description="Greet someone by name")
def greet(self, name: str) -> str:
return f"Hello, {name}!"
For subagents, the @subagent method is a factory — it receives (model, config, context) and returns a compiled LangGraph runnable. See Subagents Architecture for the full subagent pattern.
Module Self-Containment (IG-047)
Plugins should follow the self-containment pattern: each subagent or tool group is a complete module with its own __init__.py (plugin definition + public API), events.py (wire events + register_event() calls), implementation.py (factory functions), schemas.py (state/output schemas), and engine.py (LangGraph engine, if complex).
The critical detail: events are registered via side-effect imports. The events.py module calls register_event() at module load. The __init__.py imports events (with # noqa: F401) to trigger registration. Forgetting this import means events silently don’t fire — a common and hard-to-debug issue.
Configuration Validation
Plugins validate their config in on_load(). The recommended pattern is to define a Pydantic model for the plugin’s config schema and instantiate it from context.config:
from pydantic import BaseModel
class MyPluginConfig(BaseModel):
api_key: str
timeout: int = 30
class MyPlugin:
async def on_load(self, context):
self.config = MyPluginConfig(**context.config)
Environment variable interpolation (${ENV_VAR}) is handled by SootheConfig before the config reaches the plugin, so plugins receive resolved values.
Event Registration Pattern
Events follow RFC-403 namespacing: soothe.plugin.<name>.* for plugin lifecycle, soothe.tool.<component>.* for tool operations, soothe.subagent.<name>.* for subagent lifecycle. Register at module load:
from soothe.foundation.events import register_event
class MyToolEvent(SootheEvent):
type: str = "soothe.tool.my_plugin.operation"
target: str
register_event(MyToolEvent, summary_template="Operation on {target}")
The summary_template provides human-readable event summaries for the daemon’s event stream.
Testing Extensions
- Unit tests: test tool methods directly (call them as functions), test subagent factories with mock models, verify output schemas.
- Integration tests: test the full plugin lifecycle — load via
PluginLifecycleManager, verify tools/subagents appear in the registry, check event registration. - Security tests: verify workspace boundaries are enforced for any tool that touches the filesystem.
Run ./scripts/verify_finally.sh after changes — this is Soothe’s standard verification script.
Gotchas
- Built-in plugins are immutable: you cannot override
planner,deep_research,academic_research,browser_use,veritas, etc. with a lower-priority plugin of the same name. Use a different name. extra="forbid"on manifests: thePluginManifestPydantic model forbids extra fields. Passing an unrecognized argument to@plugin()raises a validation error.- Event registration is import-dependent: if
events.pyisn’t imported, events don’t register. Always include the side-effect import in__init__.py. - Graceful degradation is mandatory: a plugin that raises in
on_load()is disabled, but a plugin that crashes during tool execution can disrupt the agent loop. Wrap risky operations in try/except and return error messages rather than raising. - Optional dependencies: use try/except ImportError, not
dependenciesin the manifest. Manifest dependencies are required — if listed, the plugin won’t load without them. - Config requirements are validated:
config_requirementsentries are checked at load. A missing required config value prevents loading — make requirements optional by not listing them and checking inon_load()instead.
Related RFCs
| RFC | Title |
|---|---|
| RFC-600 | Plugin Extension System |
| RFC-601 | Built-in Plugin Agents |
| RFC-101 | Tool Interface (single-purpose design) |
| IG-047 | Module Self-Containment |
| Previous: MCP Integration | Back to: Capabilities Index |