Skip to main content
Version: 2026.1

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 when MERCURE_JWT_KEY is unset. See Real-time Sync.
  • PHP sink - coalesced incremental persistence (php-flush-sink): a placeholder is written at turn start (status streaming), parts debounce on text-delta (~1.5 s) and flush immediately on boundaries, and the message is finalized to complete at onComplete. See Session Storage.
  • POST-response sink - the in-memory buffer + listener fan-out that tailTaskToSse rides. 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 a text/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:

TypeDescription
session-createdNew session created (includes sessionId, agentName).
turn-startedFirst event of a turn - carries the stable assistant messageId and agentName; drives the per-session busy flag on every tab.
user-messageEcho 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-deltaStreaming text content (incremental); coalesced on the Mercure sink.
thinkingModel reasoning content (when available). Persisted as a thinking part.
widgetRich chat widget payload.
debugTool call start/complete notifications. Persisted as a debug part.
system-noticeTransport-recovery banner. Persisted as a separate system-role message (see Session Storage).
errorSanitised error event (generic message).
ask-userSynthetic event pushed when the agent calls ask_user.
agent-started / -completed / -failed / -switchedAgent lifecycle.
task-statusTask state changes.
title-updatedTitle generated (or updated).
turn-endedTerminal turn signal (status) emitted before complete; clears the busy flag on every tab.
completeTerminal 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).

LimitValue
Events per task5000 - exceeding trims buffer to last 1000 (ring-buffer overflow).
Buffer TTL5 minutes after task completes; then garbage collected.
ConcurrencyOne 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:

  1. Finalize the assistant message in PHP (status streamingcomplete).
  2. Generate a conversation title (first exchange only).
  3. Emit title-updated if 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

RouteMethodPurpose
/agent-server/api/chatPOSTSend message (creates session, or continues with sessionId in body).
/agent-server/api/chat/:sessionIdPOSTContinue existing session (409 if a task is already running).
/agent-server/api/chat/:sessionId/streamGETSeq-based reconnect (?seq=N). Not used by the browser - server-to-server / eval-CLI only.
/agent-server/api/chat/:sessionId/cancelPOSTCancel the running task.
/agent-server/api/chat/:sessionId/ask-user-replyPOSTReply to an open ask_user prompt.
/agent-server/api/chat/:sessionId/proposal-resolvePOSTApprove / reject / refine a proposal.
/agent-server/api/chat/:sessionId/proposalsGETCurrent proposal statuses for the session.
/agent-server/api/sessionsGETList the current user's sessions.
/agent-server/api/sessions/:idGETSession with message history (the catch-up read).
/agent-server/api/sessions/:idDELETEDelete a session.
/agent-server/api/agentsGETList available agents.
/agent-server/api/uploadPOSTUpload a file attachment.
/agent-server/api/healthGETHealth check (no auth).
/agent-server/api/admin/reload-agentsPOSTHot-reload agent YAML (admin bearer token).
/agent-server/api/admin/modelsGETList 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.