Skip to main content
Version: 2026.2

Session Storage

The agent-server is stateless - no conversation data in memory between requests. Every piece of session state is persisted to the Pimcore database so conversations survive restarts, crashes, and container redeploys, and can be listed and resumed across browser sessions. (The Copilot runtime the SDK spawns keeps a second, separate copy of the conversation on disk - see Copilot runtime session state - but PHP remains the record for the chat UI.)

What is stored

KindContents
Session metadataSession ID, user ID, current agent name, title, type (chat or task), origin (optional free-form provenance label), creation timestamp, last-activity timestamp.
MessagesFull conversation history with structured parts (text, thinking, debug, widgets, errors) and a status (pending / streaming / complete / error).
Proposal statusesResolution state of HITL proposals (pending, applied, rejected, error).

See Session type and origin below for what type and origin mean and where each is set.

PHP is the record of truth for chat. The live Mercure layer is lossy by design; a reopened, reconnected, or overnight session is reconstructed by reading PHP (see Real-time Sync). For that to work the in-progress assistant turn is persisted incrementally, not only at completion.

Where it lives

The agent-server calls the PHP Studio Backend API over HTTP. PHP writes to three tables, all created automatically by the bundle installer:

TableContent
bundle_agent_sessionsSession metadata.
bundle_agent_messagesMessage history (JSON-encoded parts).
bundle_agent_proposal_statusesProposal statuses and payloads.

Sessions survive container restarts and are available across every agent-server instance that points at the same Pimcore database.

Session type and origin

Every session carries a type: 'chat' for an ordinary Pimcore Studio chat conversation (the default), or 'task' for a session created by AgentTaskService::start() (see Features → Agent Tasks). type is set once at session creation and never changes afterward. It drives task-specific access rules and tool overrides; see Tool Security → Task-session tool overrides.

Every session also carries an optional origin: a free-form provenance label, up to 190 characters, nullable and generic on every session regardless of type. A caller starting a task can set it through AgentTaskRequest::$origin; the value is trimmed and an empty string becomes null. See Architecture → Agent Tasks → Origin for the normalization rule and why origin is a session field rather than a task field. The Pimcore Copilot automation-action step sets it to copilot-<ACTION_NAME>; see Agent Task Automation-Action Step. Nothing in the bundle sets origin on a chat session today.

In the Pimcore Studio chat sidebar, a Chats / Tasks switch above the search field selects which type the conversation list shows. A task conversation with a non-empty origin renders it as a badge (with the full value in a tooltip); a task conversation with no origin renders no badge.

Listing sessions. The paginated session list backing the sidebar (GET /pimcore-studio/api/bundle/agent/sessions) accepts an optional type query parameter (chat or task). Omitting it returns sessions of both types. The parameter is part of the request's cache key on the frontend, so the Chats view and the Tasks view keep independent paginated caches and independent infinite-scroll cursors.

Searching sessions. The same endpoint accepts an optional search query parameter. It matches case-insensitively as a substring of the session title or the agent name, and it composes with type and with the cursor pagination, so total and nextCursor describe the matching sessions. The term is trimmed, capped at 190 characters and LIKE wildcards in it are escaped, so a user searching for % or _ matches those characters literally. A blank term means no filter. The search runs in the database rather than over the pages the sidebar happens to have loaded, so it finds a conversation anywhere in the history. The sidebar debounces typing before it issues the request.

Deleting sessions in bulk. The bulk-delete endpoint accepts the same optional type and search alongside its deleteAll flag, and the sidebar sends the switch's current value and the active search term with it. A "select all" therefore deletes exactly the sessions the list is showing: task sessions only in the Tasks view, and, while a search is active, only the sessions matching it. This mirrors the count the selection banner displays. Omitting both deletes every session the user owns.

Copilot runtime session state

PHP is the record for the chat UI, but it is not what the model resumes against. The Copilot runtime the SDK spawns maintains its own copy of the conversation on disk - the model's working context: user/assistant/tool messages (including full tool results), reasoning, and compaction checkpoints. Per session it writes:

FileContent
events.jsonlAppend-on-event conversation log (the replayable transcript).
session.dbSQLite index of the session.
checkpoints/Compaction snapshots (see Interactive Input → pauseAfter and the infinite-session compaction behaviour).

client.resumeSession(id) reloads this store to restore the model's in-context conversation - e.g. after an agent-server process restart, or when a session is evicted from the adapter's in-memory cache. PHP's priorConversationContext is only a lossy text fallback (recent messages, no tool results) injected on a destroy+recreate (agent switch, token re-mint, MCP transport reset); it is not a substitute for the runtime store.

Location and durability

The storage root is the client-level baseDirectory (set in CopilotAdapter.initClient), which sets COPILOT_HOME on the spawned runtime. It is sourced from AGENT_SERVER_COPILOT_STATE_DIR (default /app/.copilot-state), bind-mounted from ./var/tmp/copilot-state in Compose.

This must be a durable mount. When baseDirectory is unset the runtime defaults to ~/.copilot inside the container's ephemeral writable layer, which is wiped on container recreation (down/up, rebuild, image bump) - the SDK then resumes an empty conversation and the agent appears to "start over". A tsx watch process restart alone keeps the container filesystem, so it does not lose history. To wipe runtime state deliberately, see Development (rm -rf var/tmp/copilot-state/*).

Lifecycle

1. Create     POST /agent-server/api/chat (new session)
→ store.createSession(meta) → sessionId returned

2. User msg The user message is persisted before the turn starts
→ store.appendMessage(sessionId, userMessage)

3. Placeholder At turn start the assistant message is created with its stable id
→ store.updateMessage(sessionId, messageId, [], 'streaming')

4. Stream The turn-so-far is flushed incrementally (coalesced)
→ store.updateMessage(sessionId, messageId, parts, 'streaming')

5. Finalize onComplete advances the status and writes the final parts
→ store.updateMessage(sessionId, messageId, parts, 'complete')

6. Title Generated after first exchange (see Request Lifecycle)
→ store.updateSession(sessionId, {title})

7. Resume User returns to / reconnects an existing session
→ store.getSession(sessionId)
→ store.getMessages(sessionId) ← includes a still-streaming message
→ store.getProposalStatuses(sessionId)

8. Delete User deletes conversation
→ store.deleteSession(sessionId)
→ Cascades: messages, proposals, uploaded files

Timing details - in particular the onComplete callback that finalizes the assistant message and triggers title generation - live in Request Lifecycle.

Incremental persistence (the record)

The agent-server's php-flush-sink writes the in-progress assistant message to PHP throughout a turn, so a reopened/overnight session reflects the turn-so-far rather than appearing only at completion:

  • Placeholder at turn start. The assistant message is upserted with its stable id and status: streaming before any content lands, so other tabs / a reload see the in-progress bubble keyed consistently.
  • Coalesced flushes. text-delta events debounce a flush (~1.5 s); any non-delta event (tool call, widget, error, notice) is a semantic boundary and flushes immediately. At most one flush is in flight (drop-coalesce, newest wins).
  • streamingcomplete. onComplete waits for any in-flight streaming flush to settle, then writes the terminal complete state so a finished turn never gets stuck streaming on reload.
  • debug and thinking parts are persisted, so the Activity and reasoning bubbles survive a reload. The agent-server's response schema (session.schema.ts) carries a debug part variant - without it the catch-up GET /sessions/:id serializer rejects any persisted message that contains one.
  • system-notice persists as a separate system-role message (not a part of the assistant message), so the transport-recovery narrative survives a reload - important for overnight turns, where transport resets are most likely to fire.

The PATCH endpoint

store.updateMessage calls a dedicated PHP route:

PATCH /pimcore-studio/api/bundle/agent/sessions/{id}/messages/{messageId}
body: { parts, status }

UpdateMessageControllerAgentSessionService::updateMessage upserts by message id (create-on-first-flush, overwrite-in-place thereafter), ownership-checked against the session's user. parts stays opaque JSON - every part the agent-server sends (text, widget, debug, thinking, …) is persisted verbatim, so a reopened session reproduces the full live turn including the Activity and reasoning bubbles.

Configuration

The session store is not a configurable backend - the agent-server always calls the PHP Studio Backend API. The only wiring knob is PIMCORE_INTERNAL_URL (default http://nginx), which tells the agent-server where to reach Pimcore. See Configuration → Environment Variables.