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 PimcoreuserId(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
ClientTokenServicewhen 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 Templatestudio-backend-default/user/{id}(least privilege; the hub rejects a trailing-/*glob), and POSTs the update toMERCURE_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 bytypeto the agent handler (AgentMessageHandler, registered on theGlobalMessageBus), which routes bysessionIdto the right pane and dispatches the matching Redux action. The same user topic also carries Studio's own job / notification traffic, distinguished bytype. - Coalescing.
text-deltaevents 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
| Concern | Mechanism |
|---|---|
| Live progress on all clients | Mercure private update → GlobalMessageBus → AgentMessageHandler. |
| Open a session (any tab) | GET /sessions/:id catch-up → reconcile by message id → resume live from the bus. |
| Gap after a disconnect | Reconnect-refetch: re-run the catch-up GET on online and on visibilitychange → visible. |
| Manual recovery | A reload button in the chat toolbar re-runs the catch-up GET. |
| Overnight / completed-while-away | Same catch-up path — PHP holds the finished (or still-streaming) message. |
| Durability / no data loss | PHP 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.