Skip to main content
Version: 2026.1

Real-time Sync

Chat is session-centric and multi-client: a turn is visible live on every tab, device, and detached widget of the same user — and on the eval CLI — not only on the tab that sent the message. This page describes the model the bundle actually implements: Mercure for liveness, PHP for correctness.

The model: PHP is the record, Mercure is the live layer

   Browser A ─┐  (ONE existing Mercure EventSource per tab, via Studio's GlobalMessageBus,
Browser B ─┤ already subscribed to studio-backend-default/user/{userId})
detached ─┘ ▲ private updates: { type:'agent-chat', sessionId, event }

┌──────────────────┴──────────────────────────────┐
│ Mercure hub (MERCURE_SERVER_URL) │
└──────────────────▲──────────────────────────────┘
│ POST (publisher JWT, shared MERCURE_JWT_KEY)
Browser B ─POST msg───▶ agent-server.TaskRunner.pushEvent ─┬─ Mercure publish (live)
eval CLI ─POST msg───▶ (turn runs to completion) ├─ PHP flush (record)
└─ POST response (eval CLI)
any client ─GET /sessions/:id ──────────────────────────────────────── catch-up

bundle_agent_messages (parts JSON + status) ← SOURCE OF TRUTH
  • Liveness comes from Mercure: the agent-server publishes each turn event to the user's topic; tabs render it instantly through GlobalMessageBus.
  • Correctness comes from PHP: the in-progress message is persisted incrementally (see Session Storage). Because PHP is authoritative, Mercure being lossy is acceptable — a catch-up read reconciles any gap.

Live delivery — per-user topic, private updates

The agent-server does not open a bespoke channel; it reuses Studio's Mercure infrastructure.

  • Topic. Events are published to Studio's existing per-user topic studio-backend-default/user/{userId}. The agent-server already knows the Pimcore userId (it owns the chat session) and threads it into the task.
  • Targeting via private updates. Each publish is a Mercure private update. The hub delivers a private update only to subscribers whose cookie JWT authorises that exact topic (minted by Studio's ClientTokenService when the app loads). A private update to one user's topic therefore reaches only that user's tabs/devices — cross-tenant isolation is enforced by the hub, not by agent-server code.
  • Publisher JWT. The agent-server signs a publisher JWT (HS256) with the shared MERCURE_JWT_KEY, scoped to the URI Template studio-backend-default/user/{id} (least privilege; the hub rejects a trailing-/* glob), and POSTs the update to MERCURE_SERVER_URL. No new subscriber auth, cookie, or PHP round-trip. See Authentication → Mercure publisher.
  • Envelope. The payload is { type: 'agent-chat', sessionId, event }. The bus routes by type to the agent handler (AgentMessageHandler, registered on the GlobalMessageBus), which routes by sessionId to the right pane and dispatches the matching Redux action. The same user topic also carries Studio's own job / notification traffic, distinguished by type.
  • Coalescing. text-delta events are batched (~75 ms window) by a per-turn batcher before publishing, so a fast token stream does not fire one hub POST per token; non-delta events flush the buffer and publish immediately. At most one publish is in flight per session.

If MERCURE_JWT_KEY is unset/blank the publisher degrades to a no-op (with a startup warning): chat still works, but without live cross-client sync — PHP remains the record and the catch-up read still reconstructs sessions.

Busy state across tabs

Concurrency stays single-writer (a second concurrent send gets HTTP 409). To make that visible everywhere, the TaskRunner emits turn-started (first event of a turn, carrying the assistant messageId) and turn-ended (before complete). The frontend slice derives a per-session busy flag from these — and from the streaming status of a caught-up message — so every tab disables its composer while a turn runs. A live delta keeps the flag fresh; a staleness guard prevents a stranded composer if turn-ended is lost.

Reliability — what guarantees what

ConcernMechanism
Live progress on all clientsMercure private update → GlobalMessageBusAgentMessageHandler.
Open a session (any tab)GET /sessions/:id catch-up → reconcile by message id → resume live from the bus.
Gap after a disconnectReconnect-refetch: re-run the catch-up GET on online and on visibilitychangevisible.
Manual recoveryA reload button in the chat toolbar re-runs the catch-up GET.
Overnight / completed-while-awaySame catch-up path — PHP holds the finished (or still-streaming) message.
Durability / no data lossPHP incremental persistence — the record, independent of any connected client.

What this model does not provide (accepted): zero-message-loss live delivery, guaranteed ordering across a disconnect, real-time visibility during a multi-minute outage. PHP reconciliation makes all of these eventually-correct, which is the intended bar.

A turn that was interrupted by an agent-server restart mid-turn is out of scope to recover; a streaming message with no live task is surfaced as interrupted rather than spinning forever.

Configuration

No new environment variables. The agent-server consumes the existing shared MERCURE_JWT_KEY and MERCURE_SERVER_URL (the same secret the hub validates against), forwarded into the agent-server service via docker-compose (see Installation → Add the Agent-Server Service). For the variable reference see Configuration → Environment Variables.

The eval CLI is unaffected by the live layer — it still consumes the POST text/event-stream response and uses the seq-based GET /chat/:id/stream reconnect path. It gains browser-observability for free: events it drives are published to the user topic like any other turn.