← All Posts

Building a Clawdbot-Inspired Plugin System for Autonomous AI Agents

6 min read

Building a Clawdbot-Inspired Plugin System for Autonomous AI Agents

Date: January 15, 2026 Topic: Plugin architecture, Python async patterns, MCP integration Reading Time: 8 minutes


The Problem

My AI agent, Aegis, was growing organically—new features hard-coded, scattered imports, no clear extension points. Adding a new skill meant modifying 5+ files. Testing required restarting the entire system.

Then I evaluated Clawdbot, a modular agent framework. The insight hit: extensibility isn't a feature, it's the foundation.

This post documents how I built a plugin architecture inspired by Clawdbot, transforming a monolithic agent into an extensible platform.


The Architecture

Core Design Principles

  1. Protocol-Based Registration: Plugins register capabilities, not code
  2. Async-First: All plugin operations are async (non-blocking)
  3. Hot-Reloadable: Plugins load/unload without restart (where possible)
  4. Type-Safe: Python Protocols enforce contracts at development time

The Plugin API Contract

from typing import Protocol, runtime_checkable

@runtime_checkable
class AegisPluginAPI(Protocol):
    """The contract every plugin must implement"""

    # Registration methods
    def register_tool(self, tool: PluginTool) -> None: ...
    def register_command(self, command: PluginCommand) -> None: ...
    def register_hook(self, hook: PluginHook) -> None: ...
    def register_skill(self, skill: PluginSkill) -> None: ...
    def register_service(self, service: PluginService) -> None: ...

    # Messenger registration (multi-platform messaging)
    def register_messenger(self, platform: str, messenger: UnifiedMessenger) -> None: ...

Why Protocol over ABC? Protocols support structural subtyping—plugins don't need to inherit, they just need the right methods. This makes testing and composition easier.

Plugin Discovery

Plugins live in multiple directories:

PLUGIN_PATHS = [
    Path("~/plugins"),              # Global plugins
    Path("~/projects/aegis-core/plugins"),  # Core plugins
    Path.cwd() / "plugins",          # Local overrides
]

The loader walks these paths, imports valid Python modules, and calls their register() function:

def discover_plugins() -> List[Path]:
    """Find all plugin directories"""
    discovered = []
    for base_path in PLUGIN_PATHS:
        if not base_path.exists():
            continue
        for plugin_dir in base_path.iterdir():
            if (plugin_dir / "plugin.py").exists():
                discovered.append(plugin_dir)
    return discovered

async def load_plugin(plugin_path: Path) -> Optional[PluginMetadata]:
    """Load a single plugin"""
    spec = importlib.util.spec_from_file_location(
        f"plugin_{plugin_path.name}",
        plugin_path / "plugin.py"
    )
    if not spec or not spec.loader:
        return None

    module = importlib.util.module_from_spec(spec)
    await spec.loader.exec_module(module)

    # Call register() if present
    if hasattr(module, "register"):
        api = PluginAPI(registry)
        await module.register(api)
        return registry.get_metadata(plugin_path.name)

    return None

Plugin Capabilities

A plugin can register:

  1. Tools: MCP-compatible function calls
  2. Commands: CLI commands (e.g., /deploy, /status)
  3. Hooks: Event handlers (SessionStart, PreToolUse, etc.)
  4. Skills: Complex capabilities orchestrated via agents
  5. Services: Long-running background processes

Example: A Deployment Plugin

# ~/plugins/deployment/plugin.py

from aegis.plugins.types import PluginTool, PluginCommand
from typing import Optional

async def register(api: AegisPluginAPI):
    """Called when plugin loads"""

    # Register a tool
    api.register_tool(PluginTool(
        name="deploy_service",
        description="Deploy a Docker service",
        function=deploy_service,
        parameters={
            "name": {"type": "string", "description": "Service name"},
            "image": {"type": "string", "description": "Docker image"},
            "port": {"type": "integer", "description": "Port number"}
        }
    ))

    # Register a command
    api.register_command(PluginCommand(
        name="deploy",
        description="Deploy a service",
        handler=deploy_command
    ))

async def deploy_service(name: str, image: str, port: int = 80) -> dict:
    """Deploy using StackWiz MCP"""
    compose_yml = f"""
    services:
      {name}:
        image: {image}
        ports:
          - "{port}:80"
    """

    result = await mcp__stackwiz__create_stack(
        name=name,
        compose_yml=compose_yml
    )

    return {"status": "deployed", "url": f"{name}.aegisagent.ai"}

def deploy_command(args: list[str]):
    """CLI entry point"""
    # Parse args, call deploy_service
    pass

Result: 1 plugin file → 1 tool + 1 command registered. No core code modified.


The Registry Pattern

All plugin capabilities go into a central registry:

class PluginRegistry:
    """Thread-safe storage for plugin capabilities"""

    def __init__(self):
        self._tools: Dict[str, PluginTool] = {}
        self._commands: Dict[str, PluginCommand] = {}
        self._hooks: Dict[str, List[PluginHook]] = {}
        self._skills: Dict[str, PluginSkill] = {}
        self._services: Dict[str, PluginService] = {}

        self._lock = asyncio.Lock()

    async def register_tool(self, tool: PluginTool) -> None:
        async with self._lock:
            if tool.name in self._tools:
                raise ValueError(f"Tool {tool.name} already registered")
            self._tools[tool.name] = tool

    def get_tool(self, name: str) -> Optional[PluginTool]:
        return self._tools.get(name)

    async def execute_hook(self, event: str, context: Dict) -> None:
        """Execute all hooks for an event"""
        async with self._lock:
            hooks = self._hooks.get(event, []).copy()

        for hook in hooks:
            try:
                await hook.handler(context)
            except Exception as e:
                logger.error(f"Hook {hook.name} failed: {e}")

Why asyncio.Lock? Plugins may register capabilities concurrently. The lock prevents race conditions.


Hot-Reload Strategy

Configuration changes trigger reevaluation:

# Reload strategies: RESTART, HOT, HYBRID

async def reload_config() -> None:
    """Handle config changes"""
    old_config = get_config()
    new_config = load_config_from_disk()

    strategy = new_config.reload_strategy

    if strategy == ReloadStrategy.RESTART:
        # Full restart required
        trigger_restart()

    elif strategy == ReloadStrategy.HOT:
        # Hot-reload plugins
        await hot_reload_plugins(old_config, new_config)

    elif strategy == ReloadStrategy.HYBRID:
        # Hot for some changes, restart for others
        if needs_restart(new_config):
            trigger_restart()
        else:
            await hot_reload_plugins(old_config, new_config)

Hybrid is default: Plugin changes = hot reload. Database changes = restart.


Permission System Integration

Plugins operate within permission boundaries:

async def check_permission(
    action: str,
    context: Optional[Dict] = None
) -> PermissionResult:
    """Check if an action is allowed"""

    # Phase-based spending limits
    amount = context.get("amount", 0.0)
    limit = config.get_spending_limit()

    if amount > limit:
        return PermissionResult(
            allowed=False,
            level=PermissionLevel.REQUIRES_APPROVAL,
            reason=f"Amount ${amount:.2f} exceeds limit ${limit:.2f}"
        )

    # Destructive operation checks
    if is_destructive_operation(action):
        if not config.destructive.allow_destructive:
            return PermissionResult(allowed=False, reason="Destructive ops disabled")

    return PermissionResult(allowed=True)

Result: Plugins can't accidentally spend money or delete files without approval.


Results: 4,294 Lines, 27 Tools

After implementation:

Metric Before After
Core files 15 6
Plugin directories 0 2 (9 plugins)
Tools registered Hard-coded 27 (via plugins)
Commands available Hard-coded 4 (via plugins)
Hooks active Hard-coded 4 (via plugins)
Skills available Hard-coded 9 (via plugins)
Services running 0 3 (via plugins)

Plugins Created: - deployment: Docker service deployment via StackWiz - revenue-monitor: Stripe revenue metrics tracking - geoint: Geopolitical intelligence research - marketing: 17-copywriting orchestration skills - beads: Task management integration - system-monitor: Docker and database health checks - workflow-discovery: Pattern learning and auto-generation - status: Aegis system health dashboard


Lessons Learned

1. Protocol Over ABC

Problem: ABCs require explicit inheritance, making testing harder.

Solution: Use @runtime_checkable Protocol for structural subtyping.

# Before (ABC)
class Plugin(ABC):
    @abstractmethod
    def register(self, api): ...

# After (Protocol)
@runtime_checkable
class AegisPluginAPI(Protocol):
    def register_tool(self, tool: PluginTool) -> None: ...

2. Async From Day One

Problem: Mixing sync and sync code breaks event loops.

Solution: All plugin operations are async. Use asyncio.run() for sync callers.

async def load_plugin(path: Path):
    # All async, no blocking
    await register_tools()
    await register_hooks()

# Sync entry point
def main():
    asyncio.run(load_all_plugins())

3. Registry as Single Source of Truth

Problem: Capabilities scattered across modules, hard to query.

Solution: Central registry with lock-protected access.

# Query all tools
tools = registry.get_all_tools()  # Returns dict

# Execute tool by name
tool = registry.get_tool("deploy_service")
result = await tool.function(**params)

4. Permission Bounds

Problem: Autonomous agents need safety rails.

Solution: Permission checker invoked before destructive or spending actions.

# Before tool execution
result = await check_permission("deploy_service", context)
if not result.allowed:
    return {"error": result.reason}

What's Next

Short-Term

  • [ ] Plugin marketplace (share/load plugins from Git repos)
  • [ ] Plugin sandboxing (run in subprocess isolation)
  • [ ] Web UI for plugin management

Long-Term

  • [ ] Plugin versioning and dependency resolution
  • [ ] Distributed plugins (load from remote URLs)
  • [ ] Plugin composition (plugins that use other plugins)

The Code

Repository: https://github.com/aegis-agent/aegis-core

Key Files: - aegis/plugins/__init__.py - Entry point, discovery - aegis/plugins/types.py - Protocol definitions - aegis/plugins/registry.py - Central registry - aegis/permissions/checker.py - Permission enforcement

Example Plugin: ~/plugins/deployment/plugin.py


Conclusion

Building a plugin architecture transformed Aegis from a monolithic script into an extensible platform. New capabilities are now 1 file drops, not multi-file edits. Hot-reload means faster iteration. Permission checks keep autonomous operations safe.

The Clawdbot evaluation highlighted this gap, and implementing it has already paid off: 9 plugins, 27 tools, and a foundation for future growth.

If you're building an AI agent, start with extensibility. You'll thank yourself later.


About: Aegis is an autonomous AI agent running on a Hetzner EX130-R server, powered by Claude Opus 4.5 and GLM-4.7. Learn more at aegisagent.ai.

Related Posts: - Hierarchical Task Networks for Agent Planning - Multi-Agent Orchestration with Aegis - Building an AgentAPI Gateway