When I first slipped a Claude‑based desktop assistant into my workflow, the only UI was a tiny console window that printed raw JSON. The moment Claude tried to call a *search* tool, the terminal froze, the user had no way to cancel, and the conversation context exploded past the 128 KB window. The result? A “helpful” AI that was anything but helpful.
The cure is a **modular plugin system** with a responsive GUI that streams Claude’s tokens in real time, isolates faulty tools, and respects rate limits. Below is a production‑ready, end‑to‑end blueprint you can copy‑paste into a fresh Python 3.12 environment and have a fully‑featured Claude agent UI running in minutes.
—
- A plugin registry loads, validates, and hot‑reloads tool modules safely.
- Async / await combined with PyQt6 signals streams Claude tokens without blocking the UI.
- Each tool runs inside a try/catch sandbox; failures never crash the agent.
- Rate‑limit handling uses a token‑bucket algorithm shared across all tool calls.
- Conversation history is persisted to SQLite and pruned with summarization to stay under the context window.
Before you start: Python 3.12+, Anthropic SDK v1.10+, PyQt6 ≥ 6.6, python‑dotenv, aiohttp, asyncio, watchdog, lxml/beautifulsoup4, SQLite3 (built‑in). Grab an Anthropic API key and store it in a `.env` file (`ANTHROPIC_API_KEY=…`).
What is a Claude AI Plugin Architecture?
A plugin architecture for a Claude AI agent is a modular system for adding custom tools. It involves a central plugin registry, a base class for all tools (with `execute` and `metadata` methods), and an event loop integrating the Anthropic SDK. This allows dynamic loading, independent management, and safe execution of tools — like web search or data processing — from within a GUI or CLI agent, following principles of loose coupling and single responsibility.
Core principles
| Principle | Why it matters |
|---|---|
| **Single Responsibility** | Each tool does one thing and declares its input schema, making contracts explicit. |
| **Message Bus over Direct Mutation** | Decouples UI, agent core, and plugins; prevents race conditions when multiple async tasks touch shared state. |
| **Lifecycle Hooks** | `load → init → execute → terminate` gives a predictable point to allocate resources or clean up. |
**My take:** I’ve found the “microservice‑in‑process” model—treating every tool as a tiny, stateless service—wins over monolithic command‑dispatch patterns. It lets you swap a broken search plugin without restarting the whole GUI.
Data flow at a glance
flowchart LR
A[User input] --> B[Qt UI Thread]
B --> C[Async Agent Loop]
C --> D[Claude SDK]
D --> E[Tool Registry]
E --> F[Plugin (async execute)]
F --> G[Result injected]
G --> C
C --> H[Stream tokens to UI]
H --> B
The UI never blocks because the heavy lifting lives in the asyncio event loop (C). All communication back to the UI happens through Qt signals (H).
—
Stack & toolchain (2026)
| Layer | Library | Reason |
|---|---|---|
| **Claude API** | `anthropic>=1.10` | First‑class async support, built‑in `Tool` class. |
| **Environment** | `python-dotenv` | Keeps secrets out of source control. |
| **GUI** | **PyQt6** (native desktop) – choose Tkinter for ultra‑light or Streamlit for web‑based rapid prototyping. | |
| **Async I/O** | `asyncio` + `aiohttp` | Non‑blocking HTTP calls; essential for streaming Claude tokens. |
| **Live reload** | `watchdog` | Monitors the `/plugins/` folder and triggers `importlib.reload`. |
| **Parsing** | `lxml`, `beautifulsoup4` | Fast HTML extraction for search plugins. |
| **Persistence** | Built‑in `sqlite3` (or tiny JSON for configs) | Durable conversation history and plugin state. |
| **Packaging** | `PyInstaller` (standalone exe) / Docker (server‑side) | One‑click distribution. |
If you prefer a browser UI, check out my companion post **[Creating a Dedicated Agent GUI with Streamlit and Claude Desktop (2026)](https://nileshblog.tech/?p=7205)** for a Streamlit‑based variant.
—
Plugin loader & registry
1️⃣ Define the abstract base class
# plugins/base.py
# SPDX-License-Identifier: MIT
# Requires: Python >=3.12, anthopic >=1.10
import abc
from typing import Any, Dict
class ClaudePlugin(abc.ABC):
"""All tool plugins must inherit from this."""
@property
@abc.abstractmethod
def metadata(self) -> Dict[str, Any]:
"""Return a dict with name, description, input_schema (JSON Schema)."""
@abc.abstractmethod
async def execute(self, **kwargs) -> Any:
"""Run the tool logic. Must be async and raise on failure."""
2️⃣ Registry implementation
# core/registry.py
# SPDX-License-Identifier: MIT
# Requires: watchdog, importlib, pathlib, logging, asyncio
import importlib
import logging
import sys
from pathlib import Path
from types import ModuleType
from typing import Dict, Type
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from plugins.base import ClaudePlugin
log = logging.getLogger(__name__)
class PluginRegistry:
"""Manages dynamic discovery, loading, and hot‑reloading of plugins."""
PLUGIN_ROOT = Path(__file__).parent.parent / "plugins"
def __init__(self):
self._plugins: Dict[str, Type[ClaudePlugin]] = {}
self._modules: Dict[str, ModuleType] = {}
self._observer = Observer()
self._start_watcher()
# ------------------------------------------------------------------ #
# Discovery
# ------------------------------------------------------------------ #
def discover(self):
"""Import every .py file under PLUGIN_ROOT (excluding __init__)."""
for py_file in self.PLUGIN_ROOT.rglob("*.py"):
if py_file.name.startswith("__"):
continue
rel_path = py_file.relative_to(self.PLUGIN_ROOT)
module_name = ".".join(rel_path.with_suffix("").parts)
self._load_module(module_name)
# ------------------------------------------------------------------ #
# Loading / Reloading
# ------------------------------------------------------------------ #
def _load_module(self, module_name: str):
try:
if module_name in self._modules:
# Hot‑reload
mod = importlib.reload(self._modules[module_name])
log.info(f"Reloaded plugin module {module_name}")
else:
mod = importlib.import_module(f"plugins.{module_name}")
self._modules[module_name] = mod
log.info(f"Loaded plugin module {module_name}")
# Register any subclass of ClaudePlugin
for attr in dir(mod):
candidate = getattr(mod, attr)
if (
isinstance(candidate, type)
and issubclass(candidate, ClaudePlugin)
and candidate is not ClaudePlugin
):
self._plugins[candidate.__name__] = candidate
log.debug(f"Registered plugin class {candidate.__name__}")
except Exception as e:
log.error(f"Failed to load plugin {module_name}: {e}")
# ------------------------------------------------------------------ #
# Public API
# ------------------------------------------------------------------ #
def get(self, name: str) -> ClaudePlugin:
"""Instantiate a plugin by its class name (sandboxed)."""
cls = self._plugins.get(name)
if not cls:
raise KeyError(f"No plugin named {name}")
return cls()
def list_plugins(self) -> Dict[str, Dict]:
"""Return a dict of plugin metadata for UI population."""
return {name: cls().metadata for name, cls in self._plugins.items()}
# ------------------------------------------------------------------ #
# Live‑reload watcher
# ------------------------------------------------------------------ #
def _start_watcher(self):
handler = _ReloadHandler(self)
self._observer.schedule(handler, str(self.PLUGIN_ROOT), recursive=True)
self._observer.start()
def shutdown(self):
self._observer.stop()
self._observer.join()
class _ReloadHandler(FileSystemEventHandler):
"""Triggers reload on file change."""
def __init__(self, registry: PluginRegistry):
self.registry = registry
def on_modified(self, event):
if event.is_directory or not event.src_path.endswith(".py"):
return
rel = Path(event.src_path).relative_to(self.registry.PLUGIN_ROOT)
module_name = ".".join(rel.with_suffix("").parts)
self.registry._load_module(module_name)
The registry isolates each plugin in its own class, catches import errors, and uses `watchdog` to hot‑reload when you edit a file. No UI restart required.
3️⃣ Directory layout
my_claude_agent/
├─ plugins/
│ ├─ __init__.py
│ ├─ base.py # abstract class
│ ├─ tools/
│ │ ├─ __init__.py
│ │ └─ web_search.py
│ └─ ui_widgets/
│ └─ __init__.py
├─ core/
│ ├─ __init__.py
│ ├─ registry.py
│ └─ agent_loop.py
├─ gui/
│ └─ main_window.py
├─ .env
└─ main.py
All tool implementations live under `plugins/tools/`. UI‑specific widgets can sit under `plugins/ui_widgets/` and be loaded the same way.
—
Event‑driven main loop & GUI integration
Async‑Qt bridge
Qt’s event loop runs in the main thread. To keep the UI responsive we spin an asyncio loop in a background thread and forward messages via `QtCore.pyqtSignal`.
# gui/main_window.py
# SPDX-License-Identifier: MIT
# Requires: PyQt6, asyncio
import asyncio
import json
import sys
from typing import Any
from PyQt6 import QtCore, QtGui, QtWidgets
# Signals that cross thread boundaries
class AgentSignals(QtCore.QObject):
token_received = QtCore.pyqtSignal(str) # incremental token
tool_result = QtCore.pyqtSignal(str) # JSON payload from plugin
error = QtCore.pyqtSignal(str)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, agent_task):
super().__init__()
self.setWindowTitle("Claude Agent with Plugins")
self.resize(800, 600)
# UI layout
central = QtWidgets.QWidget()
self.setCentralWidget(central)
layout = QtWidgets.QVBoxLayout(central)
self.chat_display = QtWidgets.QTextEdit(readOnly=True)
self.input_line = QtWidgets.QLineEdit()
self.send_btn = QtWidgets.QPushButton("Send")
layout.addWidget(self.chat_display)
layout.addWidget(self.input_line)
layout.addWidget(self.send_btn)
# Signals
self.signals = AgentSignals()
self.signals.token_received.connect(self.append_token)
self.signals.tool_result.connect(self.append_tool_result)
self.signals.error.connect(self.show_error)
# Connect UI actions
self.send_btn.clicked.connect(self.on_send)
self.input_line.returnPressed.connect(self.on_send)
# Start agent loop in background
self.agent_task = agent_task
asyncio.create_task(self.agent_task(self.signals))
def append_token(self, token: str):
self.chat_display.moveCursor(QtGui.QTextCursor.MoveOperation.End)
self.chat_display.insertPlainText(token)
self.chat_display.ensureCursorVisible()
def append_tool_result(self, json_str: str):
data = json.loads(json_str)
self.chat_display.append("\n--- Tool Result ---")
self.chat_display.append(json.dumps(data, indent=2))
self.chat_display.append("--- End Result ---\n")
def show_error(self, msg: str):
QtWidgets.QMessageBox.critical(self, "Agent error", msg)
def on_send(self):
user_input = self.input_line.text().strip()
if not user_input:
return
self.chat_display.append(f"\n> {user_input}\n")
self.input_line.clear()
# Push user message into the shared queue
self.signals.token_received.emit("") # trigger a UI refresh
self.agent_task.enqueue_user_message(user_input)
Agent core (async loop)
# core/agent_loop.py
# SPDX-License-Identifier: MIT
# Requires: anthropic>=1.10, aiohttp, asyncio, sqlite3
import asyncio
import json
import logging
import time
from collections import deque
from typing import Deque
from anthropic import Anthropic, ClaudeMessage, ToolResult
from plugins.registry import PluginRegistry
log = logging.getLogger(__name__)
# Simple token bucket for global rate limiting (max 10 calls / min)
class RateLimiter:
def __init__(self, rate: int, per_seconds: int):
self.rate = rate
self.per_seconds = per_seconds
self._tokens = rate
self._last = time.monotonic()
async def acquire(self):
while self._tokens <= 0:
await asyncio.sleep(0.1)
now = time.monotonic()
elapsed = now - self._last
refill = int(elapsed * self.rate / self.per_seconds)
if refill:
self._tokens = min(self.rate, self._tokens + refill)
self._last = now
self._tokens -= 1
# Persistence helpers
def init_db():
import sqlite3
conn = sqlite3.connect("conversation.db")
cur = conn.cursor()
cur.execute(
"""CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp REAL NOT NULL
)"""
)
conn.commit()
return conn
class ClaudeAgent:
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-202406"):
self.client = Anthropic(api_key=api_key)
self.model = model
self.registry = PluginRegistry()
self.registry.discover()
self._user_queue: Deque[str] = deque()
self._history_conn = init_db()
self.rate_limiter = RateLimiter(rate=10, per_seconds=60)
def enqueue_user_message(self, msg: str):
self._user_queue.append(msg)
async def _load_history(self) -> list[ClaudeMessage]:
cur = self._history_conn.cursor()
cur.execute("SELECT role, content FROM history ORDER BY id")
rows = cur.fetchall()
return [ClaudeMessage(role=row[0], content=row[1]) for row in rows]
async def _save_message(self, role: str, content: str):
cur = self._history_conn.cursor()
cur.execute(
"INSERT INTO history (role, content, timestamp) VALUES (?,?,?)",
(role, content, time.time()),
)
self._history_conn.commit()
async def _prune_history(self, max_tokens: int = 120_000):
"""Simple pruning: drop oldest messages until token count fits."""
# In production you would use tiktoken; here we estimate by word count.
messages = await self._load_history()
rough_tokens = sum(len(m.content.split()) for m in messages)
while rough_tokens > max_tokens and messages:
oldest = messages.pop(0)
cur = self._history_conn.cursor()
cur.execute(
"DELETE FROM history WHERE role=? AND content=?",
(oldest.role, oldest.content),
)
self._history_conn.commit()
rough_tokens = sum(len(m.content.split()) for m in messages)
async def _tool_handler(self, tool_name: str, tool_input: dict):
"""Sandboxed execution with per‑tool error isolation."""
try:
await self.rate_limiter.acquire()
plugin = self.registry.get(tool_name)
result = await plugin.execute(**tool_input)
return {"type": "tool_result", "content": result}
except Exception as exc:
log.exception(f"Plugin {tool_name} failed")
return {"type": "tool_error", "content": str(exc