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
| Kind | Contents |
|---|---|
| Session metadata | Session ID, user ID, current agent name, title, type (chat or task), origin (optional free-form provenance label), creation timestamp, last-activity timestamp. |
| Messages | Full conversation history with structured parts (text, thinking, debug, widgets, errors) and a status (pending / streaming / complete / error). |
| Proposal statuses | Resolution 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:
| Table | Content |
|---|---|
bundle_agent_sessions | Session metadata. |
bundle_agent_messages | Message history (JSON-encoded parts). |
bundle_agent_proposal_statuses | Proposal 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:
| File | Content |
|---|---|
events.jsonl | Append-on-event conversation log (the replayable transcript). |
session.db | SQLite 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
baseDirectoryis unset the runtime defaults to~/.copilotinside 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". Atsx watchprocess 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: streamingbefore any content lands, so other tabs / a reload see the in-progress bubble keyed consistently. - Coalesced flushes.
text-deltaevents 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). streaming→complete.onCompletewaits for any in-flight streaming flush to settle, then writes the terminalcompletestate so a finished turn never gets stuckstreamingon reload.debugandthinkingparts are persisted, so the Activity and reasoning bubbles survive a reload. The agent-server's response schema (session.schema.ts) carries adebugpart variant - without it the catch-upGET /sessions/:idserializer rejects any persisted message that contains one.system-noticepersists as a separatesystem-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 }
UpdateMessageController → AgentSessionService::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.