Request Lifecycle
This page covers the full end-to-end path of a chat request: what happens between "user hits send" and "session is persisted," including the asynchronous execution model that keeps tasks running when the browser disconnects.
Live progress travels over Mercure. Each turn event is published to the user's existing Studio Mercure
topic and consumed by every tab/device through Studio's GlobalMessageBus; the in-progress assistant message is
incrementally persisted to PHP (the record). For the multi-client model and its reliability guarantees see
Real-time Sync; for what survives a reload see Session Storage.
End-to-end path
1. Studio chat widget captures the message + active editor context
2. Browser POSTs /agent-server/api/chat with session cookie + payload
3. Nginx proxies to agent-server:3032
4. Auth middleware validates the session cookie against Pimcore PHP
(result cached for AGENT_SERVER_AUTH_CACHE_TTL seconds)
5. Agent config resolved from the in-memory registry (by name)
6. Context is formatted into the LLM prompt (element type, ID, tags, pinned elements)
7. User message persisted; TaskRunner spawns a background task with a stable
assistant-message id and the user's Pimcore id
8. The task fans every event to three sinks (see below): the user's Mercure topic
(live), PHP (the record), and the POST response stream (eval CLI / fallback)
9. LLM turns: text deltas, tool calls, tool results, widgets, proposals
10. Tool calls to internal MCP endpoints are forwarded with the user's bearer
(permission-checked by Pimcore on every call)
11. Final text arrives → streamed as text-delta events
12. onComplete callback fires: finalize the assistant message (status complete),
generate title, emit title-updated (runs even if the browser disconnected)
13. Every tab renders from the bus; an (re)opened tab catches up from PHP
The boundary between HTTP and task execution is step 7. Everything before it is request-scoped; everything after it lives in the task and outlives the HTTP connection.
Tasks are decoupled from the HTTP connection
A task is the single agent response to a single user message. It runs in the TaskRunner service, consumes the
SDK generator independently, and writes events to an in-memory ring buffer tagged with monotonic sequence numbers.
The browser that started the turn does not read those events from the POST response - it renders them from the
Mercure bus like every other tab. The turn runs to completion regardless of who is connected.
TaskRunner.pushEvent fans each event to three sinks:
┌─▶ Mercure publish → user topic (live; every tab/device)
Browser POST /chat ──▶ TaskRunner.pushEvent ──┼─▶ PHP flush → bundle_agent_messages (the record)
eval CLI POST /chat ──▶ (runs to completion) └─▶ POST response → SSE tail (eval CLI / fallback only)
│
consumes SDK generator
independent of HTTP
│
task completes
├─ finalize assistant msg (status complete)
├─ generate title
└─ TTL cleanup 5 min (live tail only)
- Mercure sink - text-deltas are coalesced (~75 ms window) by the per-turn batcher before being published as a
private update to
studio-backend-default/user/{userId}; non-delta events flush the buffer and publish immediately. No-ops whenMERCURE_JWT_KEYis unset. See Real-time Sync. - PHP sink - coalesced incremental persistence (
php-flush-sink): a placeholder is written at turn start (statusstreaming), parts debounce on text-delta (~1.5 s) and flush immediately on boundaries, and the message is finalized tocompleteatonComplete. See Session Storage. - POST-response sink - the in-memory buffer + listener fan-out that
tailTaskToSserides. The browser sends fire-and-forget and reads this response only for the ack / an early synchronous error; the eval CLI still consumes it as atext/event-stream.
This is what makes reconnection work: a task has no dependency on the HTTP connection that started it.
Event types
Every event the TaskRunner emits carries { type, seq, ... }. The full set:
| Type | Description |
|---|---|
session-created | New session created (includes sessionId, agentName). |
turn-started | First event of a turn - carries the stable assistant messageId and agentName; drives the per-session busy flag on every tab. |
user-message | Echo of the just-persisted user message (messageId, parts) so the user's other tabs/devices render it live. The originating tab dedups by id. |
text-delta | Streaming text content (incremental); coalesced on the Mercure sink. |
thinking | Model reasoning content (when available). Persisted as a thinking part. |
widget | Rich chat widget payload. |
debug | Tool call start/complete notifications. Persisted as a debug part. |
system-notice | Transport-recovery banner. Persisted as a separate system-role message (see Session Storage). |
error | Sanitised error event (generic message). |
ask-user | Synthetic event pushed when the agent calls ask_user. |
agent-started / -completed / -failed / -switched | Agent lifecycle. |
task-status | Task state changes. |
title-updated | Title generated (or updated). |
turn-ended | Terminal turn signal (status) emitted before complete; clears the busy flag on every tab. |
complete | Terminal event - closes the POST-response SSE tail. |
debug and thinking are collected as message parts (collectPart) and persisted, so a reopened or overnight
session reproduces the Activity / reasoning bubbles.
Memory bounds
The in-memory buffer is only the live tail for the POST-response sink and seq-based reconnect. Durability is PHP's job: PHP is the record (see Session Storage).
| Limit | Value |
|---|---|
| Events per task | 5000 - exceeding trims buffer to last 1000 (ring-buffer overflow). |
| Buffer TTL | 5 minutes after task completes; then garbage collected. |
| Concurrency | One active task per session. Concurrent message attempts return HTTP 409. |
Reconnection & catch-up
A (re)opened tab does not replay the POST stream. It reconciles against PHP and resumes live from the bus:
1. Frontend restores the session ID from localStorage
2. GET /agent-server/api/sessions/:id ← history + the turn-so-far (incl. a
streaming assistant message + parts)
3. Reconcile by message id into the store; resume live from the GlobalMessageBus
4. Reconnect-refetch: on `online` / `visibilitychange` (and a manual reload button),
re-run the GET to close any gap Mercure dropped while disconnected
This collapses every reconnect case - including an overnight turn that finished or is still running - to the same
path: read PHP, render, resume live. The browser-facing GET /chat/:id/stream?seq=N reconnect endpoint is not
used by the frontend; it is retained as a server-to-server / eval-CLI seq-based reconnect path.
Cancellation
POST /agent-server/api/chat/:sessionId/cancel
Triggers the task's AbortController. The SDK turn aborts cleanly, partial results are persisted, turn-ended and
complete are emitted, and onComplete still runs.
The onComplete callback
Every task - success, failure, or cancellation - finishes via the same callback:
- Finalize the assistant message in PHP (status
streaming→complete). - Generate a conversation title (first exchange only).
- Emit
title-updatedif a title was produced.
This runs server-side, regardless of whether a browser is still connected. The incremental PHP sink flushes the
turn-so-far throughout the turn; onComplete is the terminal write. The consequence is that a user can send a
message, close the tab, and come back to a fully rendered answer with its title set - on any device.
API reference
| Route | Method | Purpose |
|---|---|---|
/agent-server/api/chat | POST | Send message (creates session, or continues with sessionId in body). |
/agent-server/api/chat/:sessionId | POST | Continue existing session (409 if a task is already running). |
/agent-server/api/chat/:sessionId/stream | GET | Seq-based reconnect (?seq=N). Not used by the browser - server-to-server / eval-CLI only. |
/agent-server/api/chat/:sessionId/cancel | POST | Cancel the running task. |
/agent-server/api/chat/:sessionId/ask-user-reply | POST | Reply to an open ask_user prompt. |
/agent-server/api/chat/:sessionId/proposal-resolve | POST | Approve / reject / refine a proposal. |
/agent-server/api/chat/:sessionId/proposals | GET | Current proposal statuses for the session. |
/agent-server/api/sessions | GET | List the current user's sessions. |
/agent-server/api/sessions/:id | GET | Session with message history (the catch-up read). |
/agent-server/api/sessions/:id | DELETE | Delete a session. |
/agent-server/api/agents | GET | List available agents. |
/agent-server/api/upload | POST | Upload a file attachment. |
/agent-server/api/health | GET | Health check (no auth). |
/agent-server/api/admin/reload-agents | POST | Hot-reload agent YAML (admin bearer token). |
/agent-server/api/admin/models | GET | List available models (admin bearer token). |
Live delivery to the browser is not an agent-server endpoint - events arrive over Studio's existing Mercure
connection (GlobalMessageBus) on the per-user topic, as { type: 'agent-chat', sessionId, event } envelopes. The
incremental record is written via PATCH /pimcore-studio/api/bundle/agent/sessions/{id}/messages/{messageId} from
the agent-server to PHP (see Session Storage).
The POST-response and eval-CLI reconnect streams serialise as JSON with a type and seq field:
data: {"type":"text-delta","text":"Hello","seq":5}
Auth requirements are in Authentication; full endpoint permissions/rate limits are in Development.