Configuration System
This page explains how agent and skill configuration flows through the system - the layers, the merge order, what the agent-server fetches, and what happens when the PHP API is unreachable. For the what (YAML fields, editable values, how to create a new agent), see Configuration.
Single source of truth: the Pimcore PHP side
All agent and skill definitions live on the PHP side. The agent-server ships with zero built-in agents or skills - its Docker image only contains runtime code plus the rich-chat-widget definitions. On startup (and on every reload) the agent-server fetches the full configuration from Pimcore via a single HTTP endpoint.
┌──────────────────────────────────────────────────────────────┐
│ Pimcore (PHP) │
│ │
│ ┌─── Bundle preset layer (lowest) ──────────────────────┐ │
│ │ pimcore_agent.agents.paths[] │ │
│ │ pimcore_agent.skills.paths[] │ │
│ │ Any bundle can contribute via prepend() │ │
│ └────────────────────┬──────────────────────────────────┘ │
│ │ (merged by PimcoreAgentExtension) │
│ ┌─── Container config layer ────────────────────────────┐ │
│ │ pimcore_agent.agents.definitions (inline YAML) │ │
│ │ var/config/agent/agents/*.yaml (auto-prepended) │ │
│ └────────────────────┬──────────────────────────────────┘ │
│ │ (merged by LocationAwareConfigRepo) │
│ ┌─── SettingsStore layer (highest) ──────────────────────┐ │
│ │ Edits made via Pimcore Studio │ │
│ │ Overrides preset + container config by name │ │
│ └────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌────────────────────▼───────────────────────────────────┐ │
│ │ GET /pimcore-studio/api/bundle/agent/ │ │
│ │ configurations/export │ │
│ │ → { agents: [...], skills: [...] } │ │
│ └────────────────────┬───────────────────────────────────┘ │
└───────────────────────┼────────────────────────────────────────┘
│
│ HTTPS + admin bearer token
▼
┌──────────────────────────────────────────────────────────────┐
│ Agent-Server (Node.js) │
│ │
│ 1. initAgentRegistry() fetches the export endpoint │
│ 2. Materializes skill files to /app/var/skills/ │
│ 3. Builds the in-memory agent registry │
│ 4. On failure: starts a background retry loop │
└──────────────────────────────────────────────────────────────┘
The three configuration layers
Layers are listed highest priority first. Higher layers override lower layers by name (the name field of an agent
or skill). Full-object replacement - no field-level merging.
1. Pimcore Studio edits (highest priority)
Anything an administrator creates or changes in System → Pimcore Agent in Pimcore Studio. Stored either in the
Pimcore SettingsStore (default) or in var/config/agent/{agents,skills}/*.yaml depending on the write_target setting.
This layer is writable at runtime and is always the top of the merge stack.
2. Container config (middle priority)
Symfony container configuration under pimcore_agent.agents.definitions and pimcore_agent.skills.user_skills.
Populated two ways:
- Direct inline declarations in a project's
config/packages/*.yaml. - Auto-prepended files from
var/config/agent/agents/*.yamlandvar/config/agent/skills/*.yaml, loaded byPimcoreAgentExtension::prepend()and folded into the container config tree.
3. Bundle presets (lowest priority)
Agent YAML files and skill directories shipped by a Pimcore bundle, wired up via two declarative config keys:
| Key | Purpose | Discovery pattern |
|---|---|---|
pimcore_agent.agents.paths | Parent directories of *.yaml agent files | One agent per YAML file, filename = agent name |
pimcore_agent.skills.paths | Parent directories of skill subdirectories | Each subdir containing SKILL.md is a skill |
See Configuration → Shipping Presets for the walkthrough of both patterns.
Presets are served by AgentCollectionService, which walks every path under pimcore_agent.agents.paths and parses
the YAML files on every export request - no compile-time caching, no cache:clear required after editing a preset.
This mirrors SkillCollectionService. Presets are not merged into the user-agent DAO's view, so they never show
up in the Pimcore Studio agent editor tree. The editor is for user-owned entries only; presets are managed as code.
At export time, AgentConfigExportService combines bundle presets and user-scoped entries with full-object
replacement on name collision (user wins). Each entry is tagged source: 'bundle' or source: 'user' so
downstream consumers (agent-server, chat UI) can render a badge.
Agent-server load flow
The agent-server's initAgentRegistry() is the only entry point into the registry and runs in this order:
- Fetch the export endpoint with the shared admin bearer token.
- Materialize every skill in the response as
{skillsMaterializeDir}/{skillName}/SKILL.md(plus any additional files the bundle shipped). The target directory is wiped and rewritten on every reload so it always matches the current PHP state. - Convert each API agent into an internal
AgentDefinition, resolving each agent'sskills: [...]list to the materialized skill directories. - Swap the in-memory
agentDefinitionsatomically. In-flight sessions keep the agent config they were created with - the new set only affects the next chat request, when the adapter resolves the target agent against the freshly-loaded registry.
If any step fails (most commonly: the PHP API is unreachable at startup), the registry is left empty and a background retry loop is scheduled - see the next section.
Reload & recovery paths
The registry can be repopulated in three ways. All of them end in the same initAgentRegistry() call; only
the trigger differs.
1. PHP push on CRUD (the normal case)
Every create/update/delete/clone in Pimcore Studio runs through AgentConfigurationService or SkillConfigurationService,
which defer a call to AgentServerProxyService::triggerReload() until kernel.terminate.
That POSTs to the agent-server's /agent-server/api/admin/reload-agents endpoint with the shared admin token.
The user's API request is already finished when the reload fires, so saving in the UI never blocks on the agent-server.
2. Background retry after startup failure
If initAgentRegistry() cannot reach the PHP API at startup - Docker race, PHP restart, network hiccup -
the agent-server starts but the registry is empty. GET /agent-server/api/agents returns [] and the chat endpoint
errors out gracefully until the registry is populated.
BackgroundRegistryLoader then retries on an exponential schedule (5 s → 10 s → 20 s → 40 s → 60 s → every 60 s
thereafter). Each successful retry populates the registry and logs Agent registry retry succeeded. Calling the
manual reload endpoint cancels the pending retry and resets the counter.
3. Manual admin reload
For debugging or forced refreshes:
curl -X POST \
-H "Authorization: Bearer $AGENT_SERVER_ADMIN_TOKEN" \
http://localhost/agent-server/api/admin/reload-agents
The response includes warning summaries from the model and tool validators.
Skill materialization
The Copilot SDK expects skills to exist as files on disk - it reads SKILL.md from each directory listed in a session's
skillDirectories config. Because Pimcore ships skill content inline in the export response, the agent-server has to
write the files out before handing the paths to the SDK.
The materialization directory (default /app/var/skills/, overridable via AGENT_SERVER_SKILLS_MATERIALIZE_DIR)
is wiped and rewritten on every reload. Only the agent-server writes here - there is no merge with any on-disk state,
which keeps the "one source of truth = PHP" invariant.
For each skill in the export response:
- A subdirectory is created at
{materializeDir}/{skillName}/. - Every file in the
filesmap is written, preserving relative paths so nested resources (schemas, examples, templates shipped with a skill) land next toSKILL.md.
The SDK is passed parent directories, not individual skill subdirs. It walks each parent, discovers all
subdirectories containing SKILL.md, and loads them. To restrict a given agent to a subset, the server computes
a disabledSkills list: every discovered skill not in the agent's assigned set. This is the only way to opt agents
out of unrelated skills that happen to live under the same parent directory.
Why skill must stay enabled
Skill bodies are loaded on demand: the model pulls a skill's content into context by calling the built-in
skill tool (you'll see skill.invoked events mid-turn), not eagerly at startup. The loaded content is
cache-friendly - it joins the cached prompt prefix on the following call rather than invalidating the
system/tool-schema prefix (observed cache-hit ratios stay ~0.7–0.9+ even across several mid-turn skill loads).
If skill is excluded (e.g.
added to excludedTools), the SDK silently ignores skillDirectories and no skill content reaches the agent.
skill is therefore part of the ALWAYS_ACTIVE_TOOLS set alongside report_intent and fetch_copilot_cli_documentation,
and removing it will break skill loading with no error.
Export endpoint contract
GET /pimcore-studio/api/bundle/agent/configurations/export
Authorization: Bearer <AGENT_SERVER_ADMIN_TOKEN>
Auth: Admin bearer token, verified with hash_equals. This is a machine-to-machine endpoint - it bypasses the
Pimcore user session firewall via an access_control: PUBLIC_ACCESS rule in security.yaml
(see Installation → Configure Security).
Response shape:
{
"agents": [
{
"name": "general",
"displayName": "General Assistant",
"description": "…",
"icon": "chat",
"provider": "anthropic-cloud",
"model": "claude-haiku-4-5-20251001",
"systemMessage": "…",
"skills": ["pql-search", "general"],
"includeSdkTools": ["web_fetch", "ask_user"],
"pimcoreMcpServers": ["pimcore-data-objects-read"],
"pimcoreMetaGroups": ["pimcore-tags-read"],
"richChatWidgets": ["pimcore_open_element"],
"pauseAfter": { "toolCalls": 30, "timeoutMinutes": 3 },
"mcpServers": {},
"source": "bundle"
}
],
"skills": [
{
"name": "pql-search",
"description": "Search data objects using PQL.",
"source": "bundle",
"files": {
"SKILL.md": "---\nname: pql-search\n…"
}
}
],
"inference": {
"default_provider": "anthropic-cloud",
"providers": {
"anthropic-cloud": {
"driver": "copilot",
"auth_mode": "byok",
"provider": "anthropic",
"base_url": "https://api.anthropic.com",
"token": "${ANTHROPIC_API_KEY}",
"default_model": "claude-sonnet-4-5",
"title_model": "claude-haiku-4-5-20251001",
"wire_api": null,
"compat": null,
"available_models": {
"claude-sonnet-4-5": {
"max_context_window_tokens": 200000,
"max_prompt_tokens": 180000,
"max_output_tokens": 16000
},
"claude-haiku-4-5-20251001": {
"max_context_window_tokens": 200000,
"max_prompt_tokens": 180000,
"max_output_tokens": 8000
}
}
}
}
}
}
The inference key is omitted entirely when no pimcore_agent.inference block is configured in PHP.
Provider token values are exported as their literal ${VAR_NAME} placeholder strings - PHP never resolves
them. The agent-server resolves ${…} placeholders against its own container environment at session-config
build time, so secrets never transit the export endpoint as plain values.
The agent-server stores the parsed inference block in-memory via getInference() / getProvider(name).
Per-session provider resolution happens in provider-resolver.ts when the SDK session is created - not at
registry load time. See Architecture → Authentication → Per-provider LLM authentication.
The Pimcore Studio provider list endpoint (GET /pimcore-studio/api/bundle/agent/configurations/providers) returns
only [{ id, label }] pairs - provider names, no credentials. The agent-server model dropdown for a given
provider is populated from available_models keys (byok) or the live Copilot catalog (github).
The source field on agent/skill entries is purely informational and lets the agent-server (or the UI)
distinguish bundle presets from user-edited configurations. Any other fields present on an entry are preserved
and passed through - the response schema is intentionally forgiving so that future fields can be added without
breaking clients.