Document Schema
Documents and areabricks don't have a class definition - their editable surface is whatever the rendering Twig template
happens to call pimcore_input(…), pimcore_image(…), pimcore_areablock(…), etc. on. Two consequences for an LLM:
- To write to a document, it has to know what the template offers - names, types, addressing - without rendering it.
- Some Twig patterns (
pimcore_iterate_block, conditionals, computed names) cannot be fully recovered from static analysis, so worked examples a developer authored once compensate.
The bundle gives LLMs that information by combining three signals: AST analysis of Twig templates, the Pimcore
editable registry (types + value shapes), and co-located JSON example fixtures. The same machinery powers
get_document_schema(documentId) and get_area_brick(brickId) in the pimcore-documents-read MCP group.
What the LLM receives
When the LLM calls get_area_brick("standard-teaser"), the response combines four sources:
| Source | Carries |
|---|---|
| AST of the brick's view template | Flat list of editables ({name, type} per call site) + blocks[] + areablocks[] containers |
Pimcore EditableLoaderInterface::supports() | Classifies each pimcore_<type> call as a real editable (vs. helpers like pimcore_iterate_block); third-party editables register without bundle changes |
EditableHintRegistry (provider-based) | writeFormat + a populated example per editable type - same mechanism as data objects (Field Hint System) |
Co-located fixtures (<template>.examples/) | Optional _meta.json description + per-variant JSON files showing realistic editable combinations - the primary signal for small LLMs |
The LLM applies one substitution rule when consuming the response: replace each <index> placeholder in an editable
name with a concrete integer. That covers loop iteration (image_<index> → image_0) AND Pimcore's iterate-block
prefix rule (images:<index>.image → images:0.image) - so the agent doesn't need a separate concept of
"block-contained editable."
The MCP tool surface
Nine document tools across /pimcore-mcp/agent/pimcore-documents-{read,write,direct-write}:
| Group | Tool | Use |
|---|---|---|
| read | search_documents | Find a document by path / key / fulltext |
| read | get_document | Current state - opt in to includeContent / includeSettings / includeProperties (and optionally targetGroupId to read a personalization variant) |
| read | get_document_schema | What's POSSIBLE - editables[] (every editable plus areablock + block containers with their availableBricks palette) + settings[] + optional examples[] / templateDescription |
| read | get_area_brick | One or more bricks' schema in a single call - comma-separated ids → {results, errors}, each result with editables[] + examples[] |
| read | list_target_groups | List active personalization target groups (see Target-group variants) |
| write | create_document | New page / snippet / email / link / hardlink / folder. Pair with list_document_types |
| write | list_document_types | List configured Document Type presets |
| write | propose_document_update | HITL propose-and-review write - see HITL Proposals |
| direct-write | update_document | Direct editable update - validates pre-write; returns renderErrors[] from a post-save render pass |
get_document returns CURRENT state; get_document_schema returns POSSIBLE state. Brick instances under
<areablock>:<index>.<editable> are NOT expanded into the document schema - the agent fetches each unique brick type's
schema once via get_area_brick (batch every brick id you need into one call). The brick palette previously emitted by
the removed list_area_bricks tool now ships inline on each areablock entry (editables[].availableBricks).
Read vs. write split. Read + propose tools live in pimcore-documents-read / …-write. The single bypass-approval
tool, update_document, is isolated in pimcore-documents-direct-write so an agent's YAML can grant the propose flow
without granting unattended writes - see
HITL Proposals → Two write modes.
The AST analyzer
TemplateAnalyzer (in src/Twig/Analyzer/) walks the Twig AST and emits a TemplateSchema:
| Captured | What it represents |
|---|---|
editables[] | Every editable call site, as CallSiteEditable {name, type, …} |
areablocks[] + areablockAllowed[] | pimcore_areablock('content', { allowed: [...] }) + the allowed-brick list |
blocks[] | pimcore_block(...) / pimcore_scheduledblock(...) call sites |
includes[] | Static include paths (resolver walks them transitively) |
Editable type classification defers to Pimcore's EditableLoaderInterface::supports($type) - the analyzer extracts
the suffix from pimcore_<type> calls and asks Pimcore's own registry whether that's a real editable type. So:
- Standard editable types are recognised automatically
- Third-party bundles that register custom editables (
pimcore_myCustomThing) work without modifying this bundle - Non-editable Twig helpers (
pimcore_iterate_block,pimcore_url,pimcore_glossary) are skipped automatically
Patterns the analyzer handles
| Pattern | Captured name |
|---|---|
pimcore_input('foo') | foo |
pimcore_input('foo_' ~ i) inside for i in 0..2 | foo_<index> |
pimcore_iterate_block(pimcore_block('items')) with editables inside | Block items recorded; inner editables get the prefix: items:<index>.<leaf> |
{% set blockVar = pimcore_block('X') %} + pimcore_iterate_block(blockVar) | Same as above - one hop of {% set %} indirection is followed |
{% include 'snippets/foo.html.twig' %} | Included template's editables are merged into the parent's schema |
Patterns the analyzer cannot fully express
| Pattern | What it does | What the schema shows |
|---|---|---|
Dynamic block names: pimcore_iterate_block(pimcore_block('contents_' ~ i)) | Two distinct iteration variables (outer block-name index + inner per-iteration index) | Block surfaces as contents_<index>, but inner editables surface as bare names - folding two indices into one <index> placeholder would be ambiguous |
pimcore_input(myObject.field) | Editable name computed at runtime | Not captured - the AST can't resolve runtime expressions |
| Nested blocks (block A containing block B) | Two real iteration indices | Each block surfaces independently; the inner block's prefix has to be authored in the fixture |
| Macros passing editables as parameters | Indirection through macro args | Macro bodies are walked, but parameter-flow is not traced |
When a pattern can't be expressed, the example fixture is the workaround - the runtime LLM reads the worked example and pattern-matches the address shape. See Extending → Template Examples for how authors encode these patterns.
Co-located example fixtures
A Twig template at <dir>/<stem>.html.twig has fixtures in the sibling folder <dir>/<stem>.examples/:
templates/areas/standard-teaser/
├── view.html.twig
└── view.examples/
├── _meta.json ← optional LLM-targeted description
├── all-direct.json ← variant 1
└── mixed-types.json ← variant 2
Same convention for document templates: templates/content/portal.html.twig → templates/content/portal.examples/.
ExampleLoader resolves the sibling folder per template path on each MCP call (no separate cache - OS-level page cache
is sufficient) and folds the result into BrickSchema / DocumentSchema as description (from _meta.json),
examples[] (variants), and an optional usageHint.
For everything authoring-related - file format, address rules, value shapes, the dev workflow, the CLI commands, gotchas - see Extending → Template Examples.
Validation
ExampleLinter cross-checks every key in every fixture against the analyzer's editable-name set:
| Issue | Severity | Cause |
|---|---|---|
| Fixture key not in analyzer's name set | error | Typo, or stale fixture after a template change |
| Editable in name set with no example coverage | warning | Optional gap - author another variant if useful |
| Brick has no example folder | warning | Advisory; some bricks (single-editable) may not need one |
Run via bin/console pimcore-agent:document-schema:lint. The linter discovers *.examples/ folders by scanning roots
configured at pimcore_agent.examples.template_roots (defaults: %kernel.project_dir%/templates and
%kernel.project_dir%/bundles). For the dev workflow, see
Extending → Template Examples.
Target-group variants (personalization)
When the optional pimcore/personalization-bundle is installed, one document can carry per-target-group variants of its
editables - the base content stays canonical, and a variant overrides individual editables when a visitor matches the
group. The bundle exposes target groups through list_target_groups and threads a targetGroupId parameter through
get_document, propose_document_update, and update_document.
| Step | Tool call |
|---|---|
| Discover groups | list_target_groups → {available: bool, targetGroups: [{id, name}]}. When the personalization bundle is missing, available is false and the other document tools refuse targetGroupId. |
| Read variant content | get_document(id, targetGroupId: <id>) - every editable carries inherited: true if it falls back to base content, inherited: false if a variant override exists |
| Author variant content | propose_document_update(..., targetGroupId: <id>) or update_document(id, targetGroupId: <id>, …) with unprefixed editable names. The backend transparently writes them as persona_-<id>-_<name> rows |
Integration touchpoints:
src/Personalization/TargetGroupProviderInterface.php- abstraction over Pimcore'sTargetGroup\Listing. Implemented byPersonalizationTargetGroupProviderwhen personalization is installed; falls back toNullTargetGroupProvider(returnsavailable: false) when it is not.src/Mcp/Document/State/DocumentDataInspector.php- strips thepersona_-N-_prefix on read; surfacesinheritedper editable so the agent always sees canonical names.src/Mcp/Document/Update/DocumentOverrideApplier.php- re-applies the prefix on write, forcesappendEditables=true, rejects payloads that combinetargetGroupIdwithsettingsorproperties. Same applier feeds the proposal preview, so the modal renders the personalized output for the chosen group.
The user-facing workflow is documented in the bundled document-editing SKILL - every agent that opts into the
documents groups loads it.
Extension surface
Three places third-party bundles plug in:
| Add | How |
|---|---|
| New editable type | Register with Pimcore's EditableLoaderInterface - the analyzer picks it up via supports() |
| Format hint for a custom editable type | Implement EditableHintProviderInterface + tag with pimcore_agent.editable_hint_provider (see Field Hint System) |
| Example fixtures for project bricks / templates | Author JSON files in the sibling <template>.examples/ folder - see Extending → Template Examples |
Bundle-shipped areabricks need fixtures alongside the vendor's templates, which means the bundle author owns them. Project-local overlay support for vendor templates is a known gap.