Skip to main content
Version: 2026.1

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.

GroupWrite toolsMode
pimcore-data-objects-writecreate_data_object, propose_data_object_updateHITL (propose + approve); create_data_object writes the empty shell, update_data_object (in …-direct-write) is required to populate it
pimcore-data-objects-direct-writeupdate_data_objectDirect (no approval)
pimcore-assets-writepropose_asset_metadata_updateHITL
pimcore-assets-direct-writeupdate_asset, upload_assetDirect
pimcore-tags-writecreate_tag, propose_tag_assignmentHITL
pimcore-tags-direct-writeassign_tag, unassign_tagDirect
pimcore-documents-writecreate_document, propose_document_updateHITL — create_document writes a fresh empty document; the editable population goes through the proposal flow
pimcore-documents-direct-writeupdate_documentDirect (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 modificationDate against 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:

  1. Type a refinement message in the proposal widget or diff modal.
  2. The proposal status changes to refined.
  3. A system message is appended with structured context identifying which elements to re-propose.
  4. The user's refinement text is sent as a visible user message.
  5. 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:

ColumnTypeDescription
session_idvarchar(36)FK to session
proposal_idvarchar(36)32-char hex
statusvarchar(20)pending / applied / rejected / refined / error
timestampbigintLast status change (ms)
datajsonFull 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 modificationDate comparison 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