Architecture & Realtime

How a Synaplan installation fits together: the services Docker Compose starts, how messages are routed to AI capabilities, and how the realtime layer (Redis + Centrifugo) keeps everything in sync — on one machine or across a whole cluster.


Service Map

docker compose up -d starts more than just the app. The full standard stack:

Service Container Role
backend synaplan-backend Symfony PHP API + serves the built frontend (FrankenPHP/Caddy)
frontend synaplan-frontend Vue 3 SPA (Vite dev server in development)
worker synaplan-worker Background job consumer (Symfony Messenger) — AI processing, document indexing, widget crawling
db synaplan-db MariaDB with VECTOR support
redis synaplan-redis Shared infrastructure: cache, sessions, locks, rate limits, job queues, realtime engine
centrifugo synaplan-centrifugo WebSocket gateway for realtime features
qdrant synaplan-qdrant Vector database for AI memories, RAG, and feedback
ollama synaplan-ollama Local AI models (standard install only)
tika synaplan-tika Document text extraction
phpMyAdmin, MailHog dev tools Database UI, email testing

Redis, Centrifugo, and the worker are internal-only — they expose no host ports and need no configuration for a local install.


Message Routing (Multi-Task)

Every inbound message — web chat, widget, WhatsApp, email, or API — passes through a routing pipeline before any AI model runs:

  1. Pre-processing — attachments are extracted (Tika, OCR, Whisper transcription).
  2. Classification — rule-based routing (your per-topic task prompts) runs first; otherwise an AI sorter detects topic, language, and whether web search would help.
  3. Planning — for complex requests, an AI planner decomposes the message into a small task graph (for example: extract text → summarize → generate audio → compose reply).
  4. Execution — the task graph executes node by node (optionally in parallel for media tasks), streaming live task cards into the chat UI so users watch each step complete.
  5. Delivery — results, including multiple generated files from a single request, are delivered on the channel the message came from: chat, widget, WhatsApp, email, or webhook.

Simple requests skip the planner and go straight to a single handler — the classic fast path. Administrators control the feature via Settings → Routing in the app.


Streaming: SSE and WebSockets

Synaplan uses two push channels, each where it works best:

Channel Transport Used for
/api/v1/messages/stream SSE (Server-Sent Events) AI answer tokens, task-plan progress events (plan, task_update, task_chunk, task_file)
/connection/websocket WebSocket (Centrifugo) Live support: human takeover, typing indicators, operator notifications

SSE integration is described in Code Examples. The WebSocket layer is described below.


Centrifugo: the Realtime Gateway

Centrifugo is a dedicated WebSocket server that sits next to the Synaplan backend. The PHP backend never holds WebSocket connections itself — it publishes events to Centrifugo over an internal HTTP API, and Centrifugo fans them out to all subscribed browsers.

Browser ── WSS /connection/websocket ──► Caddy ──► Centrifugo
                                                      ▲
Backend (PHP) ── HTTP publish (internal) ─────────────┘
                                                      │
                                  Redis engine ◄──────┘  (shared across nodes)

Key design points:

  • Same-origin connection — browsers connect to /connection/websocket on your normal Synaplan domain. The built-in Caddy server proxies it to Centrifugo internally, so there is no CORS setup, no extra subdomain, and no additional TLS certificate.
  • JWT authentication — the backend mints short-lived HS256 tokens (connection + per-channel subscription tokens). Centrifugo verifies them with a shared secret (REALTIME_TOKEN_SECRET). Every subscription is authorized server-side before the token is issued.
  • Channels — events are grouped into namespaces: widget:session.* (one chat session), widget:operators.* (operator notifications for a widget), widgettyping:* (ephemeral typing previews), user:{id} (per-user), and system:* (public broadcasts).
  • Locked-down surface — only the client WebSocket endpoint is exposed publicly. Centrifugo's admin UI, HTTP API, and metrics stay on the internal network.

What this powers today: live human takeover of widget conversations (an operator takes over from the AI mid-chat), bidirectional typing indicators, and instant operator notifications for new widget messages.

Relevant settings (backend/.env): REALTIME_ENABLED, REALTIME_API_URL, REALTIME_API_KEY, REALTIME_TOKEN_SECRET, REALTIME_PUBLIC_WS_URL, REALTIME_ALLOWED_ORIGINS. In production the changeme_* defaults must be replaced — the backend refuses to mint tokens otherwise.


Redis: the Cluster Backbone

Redis is mandatory infrastructure in Synaplan — not an optional cache. One Redis instance (or one managed/HA Redis in production) carries six concerns:

Concern Why Redis
Application cache Provider status, model config, idempotency keys — consistent across every backend node
Sessions Users stay logged in no matter which node serves the request — no sticky sessions needed
Locks Cluster-wide mutexes (cron jobs, WhatsApp dedupe) — a file lock would only protect one node
Rate limiting Per-user quotas enforced globally, not per node
Job queues Symfony Messenger on Redis Streams — blocking consumers, at-least-once delivery
Centrifugo engine Cross-node WebSocket pub/sub (kept on its own logical DB so keys never collide)

Why this makes a cluster "just work"

In a multi-node deployment (e.g. three backend nodes behind a load balancer), every node runs the same containers — backend, worker, Centrifugo — and they all point at the same Redis:

                      ┌─ Node 1: backend + worker + Centrifugo ─┐
Load balancer ────────┼─ Node 2: backend + worker + Centrifugo ─┼──► shared Redis
                      └─ Node 3: backend + worker + Centrifugo ─┘        │
                                                                 (cache, sessions,
                                                                  locks, queues,
                                                                  realtime engine)
  • A WebSocket event published on node A reaches a browser connected to node C, because all Centrifugo instances share state through the Redis engine. The load balancer can route any user to any node — no sticky sessions, no lost events.
  • A background job queued by node B is picked up by whichever worker is free, because the queue lives in Redis Streams, not in any node's memory.
  • Caches never desync between nodes, scheduled jobs never run twice (locks), and rate limits hold globally.

Scaling out is therefore boring by design: add a node, point it at the same Redis and database, done.


Background Worker

Long-running work never blocks an HTTP request. The dedicated worker container consumes three Redis Streams queues via Symfony Messenger:

Queue Workload
async_ai_high Chat message processing, AI memory extraction
async_extract Document text extraction
async_index Re-vectorization, widget URL crawling, plugin provisioning

The worker recycles itself hourly (standard Symfony pattern to bound memory) and restarts automatically. In a cluster you can scale workers independently of web nodes — they compete for jobs on the shared Redis queues.

Check on it locally:

docker compose logs -f worker          # should show messenger:consume activity
docker compose exec redis redis-cli ping   # expects PONG
curl -s http://localhost:8000/api/health   # reports redis availability

Production Deployment

The production reference setup runs this stack across multiple nodes with a MariaDB Galera cluster and a shared Redis — deployment configs live in synaplan-platform, Helm charts for Kubernetes in synaplan-charts.

Checklist for clustered realtime:

  1. One shared Redis — point REDIS_DSN (and the Centrifugo engine address) of every node at the same managed/HA Redis.
  2. Identical secrets on all nodesREALTIME_TOKEN_SECRET and REALTIME_API_KEY must match everywhere; tokens minted on one node are verified on another.
  3. WebSocket-friendly load balancer — forward the Upgrade header and keep idle timeouts above 60s (Centrifugo pings every 25s).
  4. Restrict origins — set REALTIME_ALLOWED_ORIGINS to your real domain(s); never ship * to production.

Related