Custom Proposal Types
The HITL proposal system is extensible. Third-party bundles can add custom proposal types that go through the same propose-review-resolve pipeline as the built-in types.
See Features → HITL Proposals for the user-facing flow and storage model.
What You Need to Build
A custom proposal type consists of three components, each with a specific responsibility:
| Component | Layer | Responsibility |
|---|---|---|
| MCP Propose Tool | PHP | Validates the proposed changes (dry-run), stores the payload server-side, and embeds a server-only widget auto-emit directive in its result. Does NOT write. |
| Proposal Resolver | PHP | Declares the proposal's widgetType via getWidgetType(), fetches the stored payload on approval, re-checks permissions, and executes the actual write. |
| Frontend Widget | React (TypeScript) | Renders the proposal card, handles approve/reject/refine actions, optionally opens a diff modal. |
There is no widget YAML and no show_*_proposal tool. Proposal widgets are server-emitted: the
agent-server reads the widget directive out of the propose tool's result on tool.execution_complete
and renders the widget automatically. (This is what shrank the contract from four components to three —
see Rich Chat Widgets for the render-only server-emit mechanism.)
The proposalType string (e.g. 'my-custom-update') is the single binding between the resolver,
the stored payload, and the widgetType. It must be unique across all registered resolvers.
Step 1: MCP Propose Tool (PHP)
The propose tool is called by the LLM via MCP. It validates the proposed data without writing,
stores it server-side, and returns the proposalIds plus a server-only widget directive (built by
ProposalWidgetEmitter) so the agent-server auto-emits the review widget. Inject
ProposalWidgetEmitter (autowired) and accept an optional summary the model can author in the
same call.
#[McpTool(
name: 'propose_my_update',
description: 'Propose changes for user approval. Does NOT write. '
. 'Returns {results: [{proposalId}, ...]}. The review widget is shown to the '
. 'user automatically once a proposal succeeds — do NOT call any show_* tool. '
. 'End your turn after proposing.'
)]
public function execute(
#[Schema(type: 'string', description: 'JSON array of proposals')]
string $proposals,
#[Schema(type: 'string', description: 'Optional one-line caption for the review widget.')]
?string $summary = null,
): CallToolResult {
$sessionId = $this->getSessionId(); // from HasChatSession trait — reads the
// _mcp_token_reference request attribute
$entries = json_decode($proposals, true);
$results = [];
foreach ($entries as $entry) {
// 1. Validate the proposed data (dry-run)
// 2. Store the validated payload
$proposalId = bin2hex(random_bytes(16));
$this->sessionService->setProposalData(
$sessionId, $proposalId,
[
'proposalType' => 'my-custom-update',
'elementId' => $entry['id'],
'elementType' => 'data-object', // or 'asset', 'document'
'elementPath' => $element->getFullPath(),
'changes' => $entry['data'],
],
$this->securityService->getCurrentUser()->getId()
);
// Return only the proposalId. The widget fetches everything else
// (elementPath, diff, …) from the stored data on render — keeping the
// LLM out of the transcription loop avoids it dropping fields.
$results[] = ['proposalId' => $proposalId];
}
// Embed the server-only auto-emit directive + LLM-visible stop-signal. The
// emitter looks up the widgetType from the resolver's getWidgetType() by
// proposalType, collects the proposalIds, and resolves the caption.
$payload = ['results' => $results];
$proposalIds = $this->widgetEmitter->extractProposalIds($results);
if ($proposalIds !== []) {
$payload += $this->widgetEmitter->buildAutoEmit('my-custom-update', $proposalIds, $summary, null);
}
return new CallToolResult(
[new TextContent(json_encode($payload))],
isError: false
);
}
Required fields in stored data: Every proposal MUST include
proposalType,elementId,elementType, andelementPathin the payload passed tosetProposalData(). These four fields drive the widget grid (path column, element-link cell), the diff modal lookups, and the refinement handler. Type-specific payload (changes,editableData,proposedMetadata,addedTagIds, …) goes alongside.
Register with the pimcore.mcp_tool tag and assign to an MCP group:
services:
Acme\MyBundle\Mcp\Tool\ProposeMyUpdateTool:
tags:
- { name: 'pimcore.mcp_tool', group: 'my-bundle-write' }
Important: Always store proposal data via
AgentSessionService.setProposalData(). Never return paths, IDs, or other element metadata in the tool result. The LLM will relay it into the widget call and may drop or corrupt fields on long batches. The frontend bulk-fetches everything from the stored payload — that is the single source of truth.
See Custom MCP Tools for the full tool API.
Step 2: Proposal Resolver (PHP)
The resolver is called when the user approves. It fetches the stored payload, validates permissions, and executes the write.
class MyUpdateResolver implements ProposalResolverInterface
{
public function getType(): string
{
return 'my-custom-update'; // Must match proposalType from frontend
}
public function getWidgetType(): string
{
return 'my-proposal'; // Must match the widgetType registered in the frontend
}
public function resolve(array $payload, UserInterface $user): ProposalResult
{
// 1. Fetch stored data (proposalId + sessionId are injected by Node.js)
$data = $this->sessionService->getProposalData(
$payload['sessionId'],
$payload['proposalId'],
$user->getId()
);
if ($data === null) {
return new ProposalResult(success: false, error: 'Proposal not found');
}
// 2. Re-check permissions (may have changed since proposal creation)
// 3. Execute the write operation
// 4. Return result
return new ProposalResult(success: true);
}
}
Register with the pimcore_agent.proposal_resolver tag:
services:
Acme\MyBundle\Proposal\Resolver\MyUpdateResolver:
tags:
- { name: 'pimcore_agent.proposal_resolver' }
The ProposalResolverRegistry auto-discovers tagged services and routes by getType(),
and resolves the proposal's widgetType by getWidgetType() for the auto-emit path.
Error Handling
Return ProposalResult(success: false, error: '...') for expected failures. Unhandled exceptions
result in a 500 from PHP, surfaced as an error badge in the chat. The Node.js layer handles
status persistence (applied, rejected, error) automatically.
Step 3: Frontend Widget (React)
Create a React component that hydrates its rows from the bulk proposal
endpoint and renders the proposal card. Use the useAgentGetProposalsDataQuery
RTK Query hook so the diff modal's later fetch is a cache hit.
import React, { useMemo, useState } from 'react'
import { Button } from '@pimcore/studio-ui-bundle/components'
import type { WidgetRendererProps } from './widget-renderer-registry'
import { resolveProposal } from '../services/agent-stream.service'
import { useAgentGetProposalsDataQuery } from '../store/agent-api-slice'
import { ProposalLoadingShell } from './helpers/proposal-loading-shell'
const MyProposalWidget: React.FC<WidgetRendererProps> = ({ data, sessionId }) => {
const proposalIds = useMemo(
() => Array.isArray(data.proposalIds)
? (data.proposalIds as unknown[]).filter((v): v is string => typeof v === 'string')
: [],
[data.proposalIds]
)
const [submitting, setSubmitting] = useState(false)
const { data: fetched, isLoading, error } = useAgentGetProposalsDataQuery(
{ sessionId: sessionId ?? '', proposalIds },
{ skip: sessionId == null || proposalIds.length === 0 }
)
if (isLoading || fetched == null) {
return <ProposalLoadingShell count={ proposalIds.length } />
}
if (error != null) {
return <ProposalLoadingShell count={ proposalIds.length } error={ String(error) } />
}
// Each entry in fetched.data is the full payload your propose tool stored
// (proposalType, elementId, elementType, elementPath, changes, …).
const rows = proposalIds
.map(id => ({ proposalId: id, ...(fetched.data[id] ?? {}) }))
.filter(r => 'elementId' in r)
const handleApprove = async (proposalId: string): Promise<void> => {
if (!sessionId) return
setSubmitting(true)
await resolveProposal(sessionId, proposalId, 'approve', 'my-custom-update')
setSubmitting(false)
}
return (
<div>
<p>{String(data.summary ?? '')}</p>
{rows.map(row => (
<div key={row.proposalId}>
<span>{String((row as { elementPath?: string }).elementPath ?? '')}</span>
<Button onClick={() => void handleApprove(row.proposalId)} loading={submitting} type="primary" size="small">
Approve
</Button>
</div>
))}
</div>
)
}
Register in your bundle's frontend plugin:
richChatWidgetRendererRegistry.register('my-proposal', MyProposalWidget)
Tip: For multi-proposal support with batch actions, use the
useMultiProposalStatehook from the agent bundle. See the built-inMultiDataObjectProposalcomponent as a reference.
Session Restore
Widgets that show status badges (Applied/Rejected) on page reload can read proposal statuses
from the Redux store (proposalStatuses[proposalId]). The statuses are loaded automatically
via GET /agent-server/api/chat/:sessionId/proposals on session restore.
Step 4: Agent Configuration
Grant the agent your MCP tools. The proposal widget is server-emitted, so it does not go in
richChatWidgets — only LLM-callable display widgets are listed there.
pimcoreMcpServers:
- my-bundle-read
- my-bundle-write
systemMessage: |
## My operations - approval required
1. Call propose_my_update with the changes (optionally pass a summary caption).
2. The review widget is shown automatically — stop your turn.
3. The user will approve or reject.
The system message should instruct the LLM to propose and then stop — the widget appears on its own.
Resolver System
The ProposalResolverRegistry dispatches resolution to type-specific resolvers:
interface ProposalResolverInterface
{
public function getType(): string;
public function getWidgetType(): string;
public function resolve(array $payload, UserInterface $user): ProposalResult;
}
Built-in resolvers:
| Type string | Resolver | Widget type | Purpose |
|---|---|---|---|
data-object-update | DataObjectUpdateResolver | proposal-data-object | Field changes on data objects |
asset-metadata-update | AssetMetadataUpdateResolver | proposal-asset-metadata | Custom metadata on assets |
tag-assignment | TagAssignmentResolver | proposal-tags | Tag assignment on elements |
document-update | DocumentUpdateResolver | proposal-document | Editable/settings changes on documents |
agent-template | AgentTemplateResolver | proposal-agent-template | Agent Twig-template create / update / delete |
Service Tags
| Tag | Purpose |
|---|---|
pimcore_agent.proposal_resolver | Register a proposal resolver |
pimcore.mcp_tool | Register an MCP tool |
File Reference
PHP
| File | Purpose |
|---|---|
src/Proposal/ProposalResolverInterface.php | Resolver contract (getType, getWidgetType, resolve) |
src/Proposal/ProposalResult.php | Resolution result DTO |
src/Proposal/ProposalResolverRegistry.php | Resolver dispatch + getWidgetType() lookup |
src/Proposal/ProposalWidgetEmitter.php | Builds the widget auto-emit directive + stop-signal + summary fallback |
src/Proposal/Resolver/DataObjectUpdateResolver.php | Data object resolver |
src/Proposal/Resolver/AssetMetadataUpdateResolver.php | Asset metadata resolver |
src/Proposal/Resolver/TagAssignmentResolver.php | Tag resolver |
src/Controller/Studio/Proposal/ResolveController.php | REST endpoint |
Node.js
| File | Purpose |
|---|---|
agent-server/src/routes/proposal-handlers.ts | Approve/reject/refine handlers |
Frontend
| File | Purpose |
|---|---|
assets/js/src/services/agent-stream.service.ts | resolveProposal() function |
assets/js/src/widgets/helpers/use-multi-proposal-state.ts | Shared multi-proposal hook |
assets/js/src/widgets/helpers/proposal-card.styles.ts | Shared card styles |