Skip to main content
Version: 2026.1

Agent Tasks

A headless agent task lets your PHP code delegate a unit of work to an agent and get back a durable, machine-readable result - no browser, no human driving the Pimcore Studio chat UI. It is the developer entry point for anything that needs "run this agent against these instructions and elements, then tell me what happened" as a single call: a scheduled command, a webhook handler, a custom bundle, or an automation step.

This page is a developer guide: how to start a task, get its result, react to completion, and continue a conversation. For how the mechanism works underneath (the state machine, settlement, the watchdog), see Architecture → Agent Tasks.

A task runs unattended, so an agent cannot pause to ask a question mid-run - the ask_user tool is unavailable inside a task. When an agent cannot proceed, it finalizes with status: failed and explains why in summary. Human review of any pending proposals afterwards is fully supported (see Access and review).

The facade

Everything goes through one autowired service, AgentTaskServiceInterface. Inject it and call:

MethodReturnsPurpose
start(AgentTaskRequest $request)AgentTaskInfoCreate the task and dispatch its first turn. Returns in milliseconds - it does not wait for the run.
waitFor(string $taskId, int $timeoutSeconds)AgentTaskResultBlock until the task is terminal (or the timeout elapses), polling the status column.
get(string $taskId)AgentTaskInfoRead the task's current state without waiting.
continueTask(string $previousTaskId, string $instructions, ...)AgentTaskInfoStart a new task on a prior task's session, keeping the conversation.
cancel(string $taskId)voidCancel a non-terminal task; a no-op if it is already terminal, so it is safe to call twice.

Each method throws typed exceptions on failure (InvalidTaskRequestException, ExecutionUserException, TooManyTasksException, TaskDispatchException, TaskWaitTimeoutException); the interface docblock lists which method throws which.

Starting a task

Describe the work with an AgentTaskRequest:

ArgumentTypePurpose
agentNamestringThe agent configuration to run (required).
instructionsstringFree-form task instructions (required). See Giving the agent context.
initiatorstringA short tag identifying the caller ('cli', 'webhook', your own name); used for correlation and access routing.
actingUserId?intThe Pimcore user the agent acts as. Required unless the agent config sets an executionUser.
designatedUserId?intThe Pimcore user who reviews the result and owns the session. Optional; falls back to the acting user.
elements?arrayElements in scope, as [['type' => 'object'|'asset'|'document', 'id' => 123], ...].
outputSchema?arrayA JSON Schema (as a PHP array) the agent's output must validate against.
deadlineMinutes?intSoft deadline; defaults to pimcore_agent.tasks.default_deadline_minutes.
maxAutoContinues?intHow many times the run may auto-continue past a checkpoint; defaults to the configured value.
use Pimcore\Bundle\PimcoreAgentBundle\Service\AgentTaskServiceInterface;
use Pimcore\Bundle\PimcoreAgentBundle\Task\AgentTaskRequest;
use Pimcore\Bundle\PimcoreAgentBundle\Task\AgentTaskStatus;
use Pimcore\Bundle\PimcoreAgentBundle\Exception\TaskWaitTimeoutException;

final class SeoBackfillJob
{
public function __construct(
private readonly AgentTaskServiceInterface $taskService,
) {
}

public function run(): void
{
$request = new AgentTaskRequest(
agentName: 'data-management',
instructions: 'Fill in any missing SEO meta descriptions for the products in scope.',
initiator: 'seo-backfill',
actingUserId: 42, // the agent acts with this Pimcore user's permissions
designatedUserId: 42, // this user reviews any pending proposals afterwards
elements: [
['type' => 'object', 'id' => 1234],
['type' => 'object', 'id' => 1235],
],
deadlineMinutes: 15,
);

$info = $this->taskService->start($request); // returns in milliseconds; the task is now running
// $info->id - the task handle you keep
// $info->deeplink - "/pimcore-studio/?agentSession=..." to open the session in Studio

// … wait for it (below), or return now and react to the completion event (further below).
}
}

Giving the agent context

The agent sees exactly two input channels, and nothing else about the caller:

  • instructions - free-form text. Put any background, reference data, or constraints here. This is the intended place for context beyond the selected elements.
  • elements - structured pointers, rendered to the agent only as - {type} {id} lines. The agent fetches each element's details itself through its MCP tools.

initiatorContext is correlation and metadata only (for example a job-run id). It is stored on the task but is not shown to the agent, so do not use it to pass working context.

Retrieving the result

The agent produces its result by calling the finalize_task tool exactly once; that is what fills in the output, summary, and terminal status. You read that result in one of two ways.

Wait for it. waitFor() blocks until the task is terminal, polling the status column - it holds no connection to the agent-server, so abandoning the wait never disrupts the run. On timeout it throws TaskWaitTimeoutException, and the task keeps running until you cancel() it.

try {
$result = $this->taskService->waitFor($info->id, timeoutSeconds: 600);
} catch (TaskWaitTimeoutException) {
$this->taskService->cancel($info->id); // we gave up waiting; also stop the run

return;
}

if ($result->status === AgentTaskStatus::Succeeded) {
$output = $result->envelope['output'] ?? null;
// … use $output (validated against your outputSchema, if you supplied one)

return;
}

$reason = $result->envelope['error']['reason'] ?? 'unknown';
// … handle failed / cancelled; see the error.reason table below

Poll it yourself. get($taskId) returns the current AgentTaskInfo without blocking - useful when you persisted the task id and want to check on it later, or in your own polling loop.

The result envelope

Both AgentTaskResult::$envelope and AgentTaskInfo::$resultEnvelope carry the same JSON shape, whether or not you supplied an outputSchema:

{
"status": "succeeded",
"sessionId": "a1b2c3d4-...",
"deeplink": "/pimcore-studio/?agentSession=a1b2c3d4-...",
"summary": "Fixed 3 missing French descriptions, flagged 1 for review.",
"output": { "…caller-schema-conforming payload, only present when the task succeeded with one…" },
"proposals": { "applied": ["p1"], "pending": ["p2", "p3"], "rejected": [] },
"usage": {},
"error": { "reason": "…", "detail": "…" }
}
  • deeplink is a host-relative link to the task's session; prefix it with your origin to open it in Pimcore Studio (see Access and review). The same value is on AgentTaskInfo.
  • output is present only for a succeeded outcome that declared a schema. It is validated inside finalize_task, so an invalid shape never reaches you.
  • proposals buckets every proposal raised during the session by its current status (applied, pending including mid-refinement, rejected). It is a snapshot at finalization; later approvals or rejections update the underlying proposal but never rewrite the frozen envelope.
  • usage carries best-effort token/request metrics, or an empty object when none are available.
  • error is present only for a non-succeeded outcome.

error.reason values

ValueMeaning
agent_declaredThe agent called finalize_task(status: failed, reason: …). error.detail is exactly what the agent wrote.
agent_errorThe task's turn ended in an SDK/turn-level failure rather than a normal completion.
not_finalizedThe turn ended without a finalize_task call, and the automatic nudge turn still did not produce one.
schema_validationfinalize_task's output failed outputSchema validation three times in a row. error.detail carries the validator errors and the last non-conforming output.
timeoutThe task's deadline (deadlineMinutes, default pimcore_agent.tasks.default_deadline_minutes) passed while it was still running.
orphanedThe task's turn was interrupted mid-flight (commonly an agent-server restart) and never reported back.
dispatch_errorThe agent-server could not be reached, or rejected the first turn dispatch, at start().

See Troubleshooting → Agent tasks for what subsystem produces each value and what to check first.

Reacting to completion with events

Blocking on waitFor() is fine for a command or a queued job. When you would rather start a task and return - a web request, say - subscribe to AgentTaskCompletedEvent and react when the task settles. It is dispatched inline and synchronously the moment any task reaches a terminal status (from a normal finalize, a cancel(), or the watchdog).

use Pimcore\Bundle\PimcoreAgentBundle\Event\AgentTaskCompletedEvent;
use Pimcore\Bundle\PimcoreAgentBundle\Task\AgentTaskStatus;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class NotifyOnTaskCompletion implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [AgentTaskCompletedEvent::class => 'onCompleted'];
}

public function onCompleted(AgentTaskCompletedEvent $event): void
{
$task = $event->task; // AgentTaskInfo: id, sessionId, deeplink, status, resultEnvelope
if ($task->status !== AgentTaskStatus::Succeeded) {
return; // $event->previousStatus is also available if you need it
}

$summary = $task->resultEnvelope['summary'] ?? '';
// … notify a user, enqueue follow-up work, link them to $task->deeplink, etc.
}
}

With autoconfiguration on (the bundle's default), an EventSubscriberInterface is registered automatically. Treat the event as advisory fan-out: the envelope on the task row is the source of truth, no other listener's work is guaranteed to have run, and a subscriber should key on $task->id + $task->status if it needs to be idempotent.

Autonomy and HITL in a task

How much a task does on its own is decided entirely by which MCP tools its agent configuration grants - there is no task-level flag:

  • Autonomous. Grant the agent the direct-write tools (update_data_object, and friends). Changes are written immediately, under the acting user's permissions and the same stale-version guard the Studio editors use.
  • HITL (pending-mode). Grant the propose tools (propose_data_object_update, and friends) instead. Inside a task the propose tools tell the agent "recorded as pending, keep going," so it proposes the whole batch rather than stopping after the first change. Every proposal stays pending for a human to approve later, through the usual approve / reject / refine widgets (see HITL Proposals).

An agent may hold both tool groups; each call behaves per its own group.

No agent switching in tasks

A task session never offers the pimcore_switch_agent widget, regardless of the running agent's allowAgentSwitch setting (see Configuration → Agents). Agent switching is a human-confirmed, one-click action available in interactive chat only. An unattended task has no one present to confirm a switch, and switching can need to mint a fresh access token for a different execution identity, something only Pimcore PHP can do, not the agent-server mid-turn. Pick the agent for a task up front through agentName on the AgentTaskRequest (see Starting a task); continueTask() keeps running the same agent as the task it continues.

Access and review

designatedUserId decides who reviews the task. When set, that user owns the session (it appears in their Pimcore Studio session sidebar, badged as a task) and can open it to review pending proposals under their own permissions - a proposal created under the task's (possibly more privileged) acting user is only ever applied within the reviewer's own access. When omitted, ownership falls back to the acting user, so a task is never left in a session nobody can open.

Opening the session. Prefix the envelope's deeplink with your Studio origin (for example https://studio.example.com + /pimcore-studio/?agentSession=<sessionId>). Opening it loads Studio and the agent chat opens that session as a detached pane, dropping the reviewer straight on the conversation and its pending proposals. Access is still enforced: a user without access to the session cannot open it, regardless of the link.

Which users may open a task session at all is decided by a pluggable resolver keyed on the task's initiator, so an integration with its own ownership model (a collab bridge, say) can register its own rule. See Architecture → Identity and delegated access.

Continuing a task

Once a task is terminal, continueTask() starts a new task on the same session, carrying the transcript and any proposals forward and reusing the previous task's agent, acting user, and designated user. Only the new instructions (and, optionally, deadlineMinutes / maxAutoContinues) are supplied; you only ever need the previous task's id.

$next = $this->taskService->continueTask(
previousTaskId: $info->id,
instructions: 'Now translate the descriptions you just wrote into German.',
);

$result = $this->taskService->waitFor($next->id, timeoutSeconds: 600);

The pimcore-agent:task:run console command

The same facade is available from the CLI, which runs a task, waits for it, and prints the final envelope - handy as a smoke test or for one-off unattended runs.

docker compose exec php bin/console pimcore-agent:task:run <agent> <instructions> [options]
OptionDefaultMeaning
--acting-user-Pimcore user id the task acts as. Required unless the agent config sets executionUser.
--reviewer-Pimcore user id who owns/reviews the session (designatedUserId); falls back to the acting user.
--schema-A JSON Schema (as a JSON string) the finalized output must conform to.
--timeout180Seconds to wait for the task to reach a terminal state.
--deadline-minutesagent defaultOverrides the task's soft deadline.
--max-auto-continuesagent defaultOverrides the max auto-continue count.
--initiatorcliInitiator identifier stored on the task, for correlation.

The exit code is 0 only when the task reaches succeeded; a schema parse error, a start failure, or a wait timeout all exit non-zero.