Architecture
Wactorz is a Python-first asyncio actor-model framework for building multi-agent AI systems. Agents communicate via MQTT and run in a single process — start everything with one command.
Overview
The framework is built on three ideas: every agent is an independent actor with its own message loop; MQTT is the universal communication bus (between agents, to the dashboard, and to external systems); and DynamicAgent allows the LLM to write and spawn new agents at runtime without restarting the process.
┌─────────────────────────────────────────────────────────────────┐
│ User interfaces │
│ CLI · REST · Discord · WhatsApp · Telegram │
│ Web UI (port 8888) │
└────────────────────────┬────────────────────────────────────────┘
│ asyncio (single Python process)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ ActorSystem (wactorz/core/registry.py) │
│ │
│ Supervisor ── ONE_FOR_ONE restart strategy per actor │
│ ActorRegistry ── name → actor lookup, MQTT publisher │
│ │
│ MainActor LLM orchestrator, intent routing, spawn reg│
│ MonitorAgent heartbeat watchdog, alerts main on failure │
│ IOAgent MQTT↔UI gateway, routes chat to main │
│ CatalogAgent pre-built recipe library, spawns on demand │
│ InstallerAgent pip installs deps for dynamic agents │
│ HomeAssistantAgent HA REST API — entities, services, automa… │
│ HomeAssistantStateBridge streams HA state changes → MQTT │
│ HomeAssistantMapAgent entity → friendly name / domain resolver │
│ TimeSeriesCollector writes MQTT streams to SQLite time-series │
│ PlannerAgent* per-request planner, exits after planning │
│ DynamicAgent* LLM-generated Python, spawned at runtime │
│ ScheduledAgent* first-class time triggers │
└────────────────────────────┬───────────────────────────────────────────┘
* not in the supervised set — spawned and managed by the spawn registry
│ pub / sub
▼
MQTT broker (separate process — Mosquitto)
:1883 TCP (default)
│
┌──────────────┴──────────────┐
▼ ▼
Home Assistant External systems via MQTT
WebSocket API custom topics, any device or service
💡 One command to start — Running
wactorzstarts the actor system and the web dashboard. A separate MQTT broker (Mosquitto) must be running first — start it withdocker compose up -dfrom the repo root.
Components
Core — wactorz/core/
| File | Responsibility |
|---|---|
actor.py |
Actor base class — message loop, heartbeat, persistence (SQLite / Redis / Pickle), supervisor strategy enum |
registry.py |
ActorSystem, ActorRegistry, Supervisor, _MQTTPublisher (shared aiomqtt connection) |
Built-in Agents — wactorz/agents/
See the Agents reference for full documentation. A summary of the core actors:
| Agent | Role |
|---|---|
| MainActor | LLM orchestrator. Classifies intent, routes messages, manages the spawn registry and user memory. |
| PlannerAgent | Spawned per pipeline request. Generates a multi-agent plan and spawns the required DynamicAgents. |
| DynamicAgent | Executes LLM-generated Python at runtime from a setup() / process() / handle_task() code string. |
| CatalogAgent | Pre-built recipe library. Loads agents from catalogue_agents/ and spawns them on request. |
| HomeAssistantAgent | Wraps the HA REST API — entities, services, automations. Uses internal LLM calls for classification. |
| HomeAssistantStateBridgeAgent | Streams HA state changes to MQTT so pipeline agents can react to device events in real time. |
| MonitorAgent | Tracks heartbeats from all actors. Alerts main when an agent is unresponsive for >60 s. |
| InstallerAgent | Runs pip install in a subprocess. Called automatically before spawning a recipe with declared dependencies. |
| LLMAgent | Base class for LLM-backed agents. Handles conversation history, rolling summarisation, and cost tracking across all providers. |
Actor Lifecycle
Every actor in Wactorz follows the same asyncio lifecycle:
class MyAgent(Actor):
async def on_start(self):
# Called once when the actor starts.
# Long-running work (MQTT subscriptions, polling loops)
# must be launched as asyncio.create_task() — never awaited directly.
asyncio.create_task(self._my_loop())
async def handle_message(self, msg: Message):
# Called for every message in the inbox.
# msg.type is one of: START, STOP, PAUSE, RESUME, DELETE,
# TASK, RESULT, HEARTBEAT, SPAWN, TICK, STATUS_REQUEST, STATUS_RESPONSE.
if msg.type == MessageType.TASK:
result = await self._do_work(msg.payload)
await self.send(msg.reply_to, MessageType.RESULT, result)
async def on_stop(self):
# Optional cleanup.
pass
The Supervisor wraps each actor with a ONE_FOR_ONE restart policy. If an actor crashes, only that actor is restarted — others keep running. max_restarts and restart_delay are configurable per actor.
Persistence
Wactorz uses a three-tier persistence layer (wactorz/core/persistence.py) that routes each key to the appropriate store:
| Store | Location | Used for |
|---|---|---|
| SQLite | state/wactorz.db |
Durable structured data: spawn registry, pipeline rules, user facts, topic contracts, conversation history, time-series sensor/detection/HA-state data |
| Redis | redis://localhost:6379 (falls back to in-memory if Redis is unavailable) |
Ephemeral fast-access data: observed topic samples, agent metrics, heartbeat state |
| Pickle | state/{actor_name}/state.pkl |
Arbitrary Python objects: custom agent state dicts, ML models, numpy arrays, cv2 captures |
Actor.persist(key, value) and Actor.recall(key) route automatically to the correct store based on the key name. Existing agent code works without changes.
The spawn registry (_spawned_agents) is stored in SQLite. On restart, MainActor re-spawns every entry so dynamic agents and catalog agents survive reboots.
On first startup after upgrading from an older version, migrate_from_pickle() reads existing .pkl files and moves structured keys to SQLite/Redis. Pickle files are left in place for keys that remain in pickle.
MQTT Topics
| Topic pattern | Publisher | Subscriber | Payload |
|---|---|---|---|
io/chat |
IOAgent / UI | main | {from, content} |
agents/{id}/chat |
any actor | UI / IOAgent | {role, content, interface} |
agents/{id}/heartbeat |
every actor | MonitorAgent | {name, state, ts, ...} |
agents/{id}/logs |
any actor | dashboard | {type, message, ts} |
agents/{id}/manifest |
any actor | main | capabilities, input/output schema |
homeassistant/state_changes/# |
HA state bridge | pipeline agents | {entity_id, domain, new_state, old_state} |
State bridge topic format — When
HA_STATE_BRIDGE_PER_ENTITY=1the bridge publishes tohomeassistant/state_changes/{domain}/{entity_id}. When=0(default) it publishes everything to the flat topichomeassistant/state_changes. Always subscribe to the wildcard#and filter byentity_idin the payload.
Message Flow
User → Agent (any interface)
User types: "@my-agent {"action": "status"}"
│
▼
Interface (CLIInterface / DiscordInterface / RESTInterface / WhatsAppInterface / TelegramInterface)
│ calls main_actor.process_user_input(text)
▼
MainActor._classify_intent() ← one LLM call: ACTUATE | HA | PIPELINE | OTHER
│
├── ACTUATE → OneOffActuatorAgent (ephemeral) ← immediate HA device control
├── HA → send to home-assistant-agent
├── PIPELINE → spawn PlannerAgent → multi-agent pipeline + persisted rule
├── OTHER → main.chat() ← conversational reply
└── @mention detected → send directly to named actor
│
▼
target-agent.handle_message(msg)
│
▼
handle_task(agent, payload) → returns dict result
│
▼
main receives RESULT, formats, returns to user
Pipeline (HA state → Discord notification)
HA state changes (lamp turns on)
│
▼
HomeAssistantStateBridgeAgent
│ publishes homeassistant/state_changes (flat topic, per_entity=0)
▼
Mosquitto
│
▼
lamp-on-discord-notify (DynamicAgent)
│ setup(): agent.subscribe("homeassistant/state_changes/#", on_state)
│ on_state(): if entity_id == "light.wiz_..." and new_state["state"] == "on":
│ httpx.post(discord_webhook, {"content": "Lamp is on!"})
▼
Discord channel
LLM Providers
| Provider | Flag | Env var | Example model |
|---|---|---|---|
AnthropicProvider |
--llm anthropic |
ANTHROPIC_API_KEY |
claude-sonnet-4-6 (also the global LLM_MODEL default) |
OpenAIProvider |
--llm openai |
OPENAI_API_KEY |
gpt-4o |
OllamaProvider |
--llm ollama --ollama-model llama3 |
— (local; uses OLLAMA_URL) |
llama3, mistral, etc. |
NIMProvider |
--llm nim --nim-model meta/llama-3.3-70b-instruct |
NIM_API_KEY |
meta/llama-3.3-70b-instruct |
GeminiProvider |
--llm gemini --gemini-model gemini-2.5-flash |
GEMINI_API_KEY |
gemini-2.5-flash (gemini-only fallback when LLM_MODEL is unset) |
All providers implement complete(messages, system) → (text, usage) and stream(messages, system) → AsyncGenerator. Cost tracking (USD per 1M tokens) is built into every provider and accumulated in LLMAgent.metrics.
For Ollama, system is encoded as the first {"role": "system"} entry in the native /api/chat messages array for both blocking and streaming calls. This keeps local model behavior aligned with the hosted providers, which already receive system instructions through their chat-message APIs.
Supervision Tree
The supervisor is configured in cli.py inside build_system(). Each actor is registered with a factory function (called fresh on each restart), a strategy, and restart limits:
system.supervisor
.supervise("main", make_main, strategy=ONE_FOR_ONE, max_restarts=10)
.supervise("monitor", make_monitor, strategy=ONE_FOR_ONE, max_restarts=10)
.supervise("catalog", make_catalog, strategy=ONE_FOR_ONE, max_restarts=10)
.supervise("home-assistant-agent", make_ha_agent, max_restarts=5)
# ... etc
Dynamic agents (spawned by main or planner) are not in the supervision tree — they are managed by the spawn registry. On restart, main re-spawns them from the persisted _spawned_agents key in state/wactorz.db (SQLite).
Running Wactorz
The wactorz command starts the actor system and the web dashboard. It connects to an external MQTT broker (Mosquitto) that must already be running. The simplest way is docker compose up -d from the repo, which starts Mosquitto on :1883.
# Start with Anthropic Claude (default interface: CLI)
wactorz
# Choose a different LLM provider
wactorz --llm gemini --gemini-model gemini-2.5-flash
wactorz --llm openai
wactorz --llm ollama --ollama-model llama3
wactorz --llm nim --nim-model meta/llama-3.3-70b-instruct
# Hot-reload in dev (restarts on source file changes)
wactorz --reload
# Discord bot
wactorz --interface discord --discord-token $DISCORD_BOT_TOKEN
# WhatsApp (via Twilio)
wactorz --interface whatsapp
# Telegram
wactorz --interface telegram --telegram-token $TELEGRAM_BOT_TOKEN
# REST API
wactorz --interface rest --port 8000
# Custom MQTT broker (external)
wactorz --mqtt-broker 192.168.1.10 --mqtt-port 1883
# Disable web dashboard
wactorz --no-monitor
Interfaces
| Interface | Flag | Notes |
|---|---|---|
| CLI | --interface cli |
Default. Interactive terminal with streaming responses. |
| REST | --interface rest |
HTTP API on --port (default 8000). POST /chat, GET /agents. |
| MCP | separate wactorz-mcp process |
Stdio Model Context Protocol server backed by Wactorz REST. |
| Discord | --interface discord |
Bot responds in channels and DMs. Requires DISCORD_BOT_TOKEN. |
--interface whatsapp |
Via Twilio. Requires TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_NUMBER. |
|
| Telegram | --interface telegram |
Bot API. Requires TELEGRAM_BOT_TOKEN. |
The web dashboard starts automatically on http://localhost:8888 regardless of which interface is active, unless --no-monitor is passed. It shows live agent status, heartbeats, logs, and a chat interface.