HITL Proposals
Writes the agent wants to make to Pimcore data can go through a proposal: the agent proposes, the user reviews, the user approves or rejects. Nothing is written without a click. This is the central safety mechanism — HITL, "Human-in-the-Loop" — that makes it safe to let an LLM touch production data.
Five proposal types ship today: single data-object edits, asset-metadata edits, tag assignments, document edits (page / snippet / email content), and agent-generated Twig templates. All follow the same propose → review → resolve flow described below. For the template-specific flow see Agent Templates.
Two write modes, chosen by MCP group
An agent's write authority is set by which MCP groups it has access to. Every write operation has either a proposal tool or a direct-write tool — and the tools are in different groups, so agent config alone decides which an agent can call.
| Group | Write tools | Mode |
|---|---|---|
pimcore-data-objects-write | create_data_object, propose_data_object_update | HITL (propose + approve); create_data_object writes the empty shell, update_data_object (in …-direct-write) is required to populate it |
pimcore-data-objects-direct-write | update_data_object | Direct (no approval) |
pimcore-assets-write | propose_asset_metadata_update | HITL |
pimcore-assets-direct-write | update_asset, upload_asset | Direct |
pimcore-tags-write | create_tag, propose_tag_assignment | HITL |
pimcore-tags-direct-write | assign_tag, unassign_tag | Direct |
pimcore-documents-write | create_document, propose_document_update | HITL — create_document writes a fresh empty document; the editable population goes through the proposal flow |
pimcore-documents-direct-write | update_document | Direct (no approval) — the only document tool that bypasses HITL. For discovery and target-group rules see Architecture → Document Schema. |
An agent with pimcore-data-objects-write but not …-direct-write can only propose — there is no way for it to
bypass the gate. The recommended default is HITL for anything user-facing; direct writes are appropriate
only for tightly scoped, automation-style agents.
The flow
1. Propose
The agent calls a propose tool (e.g. propose_data_object_update). The PHP tool validates the payload
(a dry-run — no writes), stores the validated result server-side in bundle_agent_proposal_statuses keyed by a
generated proposalId, and returns only {proposalId} to the LLM. No element paths, IDs, or field values
flow back through the LLM. Keeping the LLM out of the transcription loop is deliberate: it prevents the model
from silently dropping fields on long batches.
The review widget then appears on its own — there is no show_*_proposal tool. When the propose tool
succeeds the agent-server emits the matching proposal widget automatically (on tool.execution_complete),
so the model just proposes and stops its turn. The propose call takes an optional summary parameter for the
widget caption; if omitted, the server generates a neutral one.
All propose tools support batching — one call can create multiple proposals, each with its own proposalId.
2. Review
What the user sees depends on how many proposals are in the batch:
- Single proposal — a compact card with View changes, Approve, Reject buttons. View changes opens a diff modal: current value on the left, proposed value on the right.
- Multiple proposals — a table with one row per element, showing path, status, and a per-row View changes. Batch actions (Approve selected, Reject selected) operate on the checked rows.
When the widget mounts, the router does one bulk fetch for every proposal payload in the batch:
GET /pimcore-studio/api/bundle/agent/proposals/{sessionId}/data?ids=a,b,c
The response is keyed by proposalId and contains elementPath, elementId, and the full diff payload. This is
the single source of truth for everything the widget renders — so anything the LLM might have forgotten or
misremembered is irrelevant. The diff modal calls the same RTK Query hook on open; its fetch is a deduped cache hit.
3. Resolve
Each proposal has four possible outcomes:
- Approve — the agent-server forwards the request to a PHP resolver, which fetches the stored payload and applies it.
The resolver re-checks permissions and compares
modificationDateagainst the stored snapshot to detect stale data before writing. After a successful resolve, the proposal widget tells Studio to reload the matching editor — but only when the element's editor tab is currently open AND has no unsaved local changes (useEditorRefresh.refreshIfOpenAndClean). An editor with pending edits is left alone so the user does not lose work. - Reject — status is recorded; nothing is modified.
- Refine — status becomes
refined; a system message with the previous change context is appended to the conversation. The user's refinement text goes in as a user message, and the agent produces a new proposal incorporating the feedback. - Error — the resolver hit a validation or permission failure. The proposal card turns red; the user sees the error message. When the failure happens while a diff / detail modal is open (data-object, document, tag, asset-metadata, or agent-template), the message is also rendered inside the modal footer next to the action buttons — otherwise the user would be stuck behind the modal with no visible feedback.
The sequence:
LLM Agent Server PHP Backend Studio Frontend
| | | |
|-- propose tool call --->|--- MCP HTTP -----> | |
| | validate + store |
|<-- {proposalId} --------|<-- scalar result--| |
| |--- widget event --> browser (auto-emitted on |
| (model stops turn) | | tool.execution_complete)
| | |<-- GET /proposals/ |
| | | {sid}/data?ids=.. |
| | |--- {data: stored} ----->|
| | [User: Approve] |
| |<-- POST /proposal-resolve ------------------------|
| |--- POST /resolve ------>| |
| | fetch stored, check perms, |
| | apply + save |
| |<-- {success} -----------| |
Refinement
Users can ask for changes without starting the proposal over:
- Type a refinement message in the proposal widget or diff modal.
- The proposal status changes to
refined. - A system message is appended with structured context identifying which elements to re-propose.
- The user's refinement text is sent as a visible user message.
- The agent produces new proposals incorporating the feedback.
For multi-proposal overviews, refinement can target individual elements or a selected subset. A batch-refine
action sets all selected proposals to refined and appends one combined system message listing which elements
to re-propose.
Storage
Proposal payloads live in bundle_agent_proposal_statuses:
| Column | Type | Description |
|---|---|---|
session_id | varchar(36) | FK to session |
proposal_id | varchar(36) | 32-char hex |
status | varchar(20) | pending / applied / rejected / refined / error |
timestamp | bigint | Last status change (ms) |
data | json | Full payload |
Why server-side? LLMs can lose JSON escaping, fabricate fields, or drop data when re-serialising complex structures. Storing the validated data at proposal time guarantees exactly that payload reaches the resolver, and saves context-window tokens on the way back through the LLM.
Security
- Proposal data is validated at creation time (dry-run via field adapters).
- Permissions are re-checked at resolution time — a user may lose access between propose and approve.
- Stale-data detection via
modificationDatecomparison prevents silent overwrites. - Session ownership is enforced on every proposal endpoint.
- The resolver reads from the database, not from the request body — no LLM-controlled writes.
Further reading
- Extending → Custom Proposal Types — add your own propose tool + resolver + widget for a domain-specific write flow.
- Rich Chat Widgets — the catalog of proposal-related widgets.
- Architecture → Authentication — how permissions are re-checked.