Skip to content

Architecture

Myrtille has three strictly separated layers and one source of truth.

┌──────────────────────────────────────────────┐
│ TOWN (game) │
│ Phaser pixel-art scene · React panels, mail │
│ Mobile PWA (mail only) │
└───────────────▲──────────────────────────────┘
│ WebSocket (state, events, actions) · HTTP
┌───────────────┐ ┌───────────┴──────────────────────────────────┐
│ Coolify │──▶│ TOWN HALL (engine) │
│ webhooks, API │◀──│ event bus · citizens · mail · actions │
└───────────────┘ │ RBAC · audit · relay · telemetry │
└───▲──────────▲───────────▲──────────▲────────┘
│ │ │ │
courier│ courier│ courier│ courier│
coolify│ claude- │ webhook │ docker, mcp, git
│ code │ │ │
▲ outbound WS ▲ outbound WS
LOCAL AGENT (developer) DOCKER AGENT (server)

The engine is the only source of truth. It keeps the registry of towns, districts, buildings and citizens, an in-process event bus with persisted events, one state machine per citizen, the mail, the actions with their authorisation and audit, reconciliation with every source, authentication and roles, and the relay endpoint for agents. Self-hosted, it stores everything in SQLite; the cloud uses Postgres with the same logical schema.

Each external tool is wrapped by a courier that implements one contract: describe its capabilities, start and stop, reconcile the full truth, turn inbound webhooks into normalised events, and execute declared actions. A courier knows nothing about the town or the mail. It receives its secrets from the engine and never stores them.

Couriers delivered with 1.0: Coolify, Claude Code, generic signed webhook, Docker, GitHub, MCP, Dokploy and OpenTelemetry.

The town is a WebSocket client of the engine. It receives the full state at connection, then deltas. Its store is the only truth of the render; the Phaser scene is a derived view. Everything shown on the map also exists as a list, and the mail works without the scene, on a phone.

  • Local agent (developer workstation): receives Claude Code HTTP hooks on 127.0.0.1, relays them to the engine, waits for permission decisions, and runs missions in the target repository with the unmodified claude command the developer signed in to. Transcripts never leave the workstation.
  • Docker agent (server): reads Docker events and logs through a restricted socket proxy and maps containers to citizens through Coolify labels.

Both always connect outbound, with a single-use enrolment token then a rotating long-term token. Nothing on your side has to accept an inbound connection.

  • Ingestion: a Coolify webhook reaches the engine, the Coolify courier normalises it, the event is persisted, the citizen state changes, a mail rule may create an item, and the change is broadcast.
  • Action: the town sends an action request; the engine checks the role, the district activation and the confirmation, writes an audit entry, lets the courier call the tool with the least powerful token for that role, and writes the result.
  • Claude Code permission: the hook reaches the local agent, the engine creates a mail item that expires five seconds before the hook timeout, the decision travels back to the agent, which answers Claude Code. If nobody decides in time, Claude Code applies its own default.