From camunda-skills
Models and configures AI agents in Camunda 8 BPMN using the AI Agent Sub-process connector with tools as BPMN activities, prompts, and call limits.
How this skill is triggered — by the user, by Claude, or both
Slash command
/camunda-skills:camunda-ai-agentsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build agentic AI processes in Camunda 8.8+: an LLM driver (the AI Agent connector, **Sub-process variant**) applied to an ad-hoc subprocess with tools modeled as BPMN activities. Covers shape, prompts, tool modeling with `fromAi()`, sub-flow tools, and multi-turn agent context.
Build agentic AI processes in Camunda 8.8+: an LLM driver (the AI Agent connector, Sub-process variant) applied to an ad-hoc subprocess with tools modeled as BPMN activities. Covers shape, prompts, tool modeling with fromAi(), sub-flow tools, and multi-turn agent context.
The older Task variant (AI Agent connector on a service task paired with an external multi-instance ad-hoc subprocess and explicit feedback loop) is documented in references/ai-agent-task.md for the niche cases where you need to audit or intercept every tool call. The Sub-process variant is the recommended choice for everything else, and is what the rest of this skill teaches.
c8ctl element-template workflow used to apply the AI Agent template (and the REST connector commonly used as a tool).fromAi() calls, result expressions, type coercion.inputSchema are derivedfromAi() FEEL functionExample — apply the template via c8ctl rather than hand-writing the many provider/prompt/memory fields:
# 0. One-time per environment: sync the OOTB catalog. search,
# get-properties, and OOTB-ID apply need this to have run at
# least once. Skip on subsequent runs.
c8ctl element-template sync
# 1. Find the current template ID and version — they evolve
c8ctl element-template search "ai agent"
# 2. Inspect the properties you care about
c8ctl element-template get-properties <id>
c8ctl element-template get-properties <id> --detailed data.systemPrompt.prompt
# 3. Apply to your ad-hoc subprocess element
c8ctl element-template apply -i <id> AgentTools process.bpmn \
--set provider.type=anthropic \
--set provider.anthropic.authentication.apiKey='{{secrets.ANTHROPIC_API_KEY}}' \
--set provider.anthropic.model.model=claude-sonnet-4-5 \
--set data.systemPrompt.prompt='="You are a customer support agent. Use the available tools to look up customers and orders, and escalate to a human only when needed."' \
--set data.userPrompt.prompt='="Customer " + customerId + " reports: " + issue' \
--set data.limits.maxModelCalls='=10'
The template handles zeebe:taskDefinition, the zeebe:adHoc collection bindings, default input mappings, and the model-provider-specific fields — they change across template versions, don't hand-code them.
Supported providers: anthropic, bedrock, azure-openai, vertex-ai, openai, plus OpenAI-compatible (custom endpoint).
The host is bpmn:adHocSubProcess — not a service task and not a regular sub-process. Tools live inside it.
bpmn:adHocSubProcess (template applied here)
└── <root activities, each one a tool>
bpmn:serviceTask — e.g., REST connector call
bpmn:scriptTask — FEEL computation
bpmn:userTask — human-in-the-loop
bpmn:subProcess — multi-step or async tool
Hard rules that the lint loop does NOT catch — verify by reading the BPMN:
bpmn:adHocSubProcess. A regular bpmn:subProcess or a service task will not host the connector.toolCallResult must be set — for a single-activity tool that's the activity itself; for a sub-flow tool, it can be any activity inside the sub-flow.Three things determine whether the LLM picks a tool correctly:
Tool name — the activity ID in the ad-hoc subprocess (e.g., LookupCustomer, GetCurrentWeather). The connector uses the BPMN id, not the human-facing name attribute, as the tool name passed to the LLM. Pick descriptive IDs.
Tool description — the value of <bpmn:documentation> on the activity. If documentation is missing, the connector falls back to the activity's name attribute, but always set documentation explicitly. Strong descriptions say what the tool does, when to use it, when not to, and what it returns:
"Look up a customer by ID. Returns the customer's name, tier, and account status. Call this when the user mentions a customer ID or name. Do not call for anonymous queries."
The LLM sees the description verbatim — keep it free of vendor names, internal URLs, or anything you wouldn't want quoted back. c8ctl element-template apply does not write <bpmn:documentation>; hand-edit it in after applying a tool connector template.
Input schema — derived automatically from fromAi() calls inside the activity's input mappings. No fromAi() calls → empty schema → the LLM can't pass parameters.
A tool can be a single activity (service task, script task, user task) or a sub-flow rooted at a bpmn:subProcess containing further activities. In both cases the LLM only sees the root node — descriptions, inputs, and schema are read from there. The internal sub-flow steps are invisible to the LLM; they execute in sequence per normal BPMN semantics and propagate variables up when the sub-process completes.
Worked XML for each of the four shapes (REST, script, user task, sub-flow) is in references/tool-modeling.md.
fromAi() tags a value as "the LLM will provide this at runtime". The first argument must be a reference to toolCall.<paramName> — the last segment becomes the LLM-visible parameter name. The function takes an optional description, type ("string" default, plus "number", "boolean", "array", "object"), a JSON-Schema fragment, and an options context for things like {required: false}.
<zeebe:input
source='="https://api.example.com/customers/" + fromAi(toolCall.id, "Customer ID", "string")'
target="url" />
Full signature, all 6 calling variants (positional, named, enum schemas, optional params, multi-call JSON bodies) in references/fromai.md.
When a tool completes, the connector reads the variable named toolCallResult from the tool's scope and forwards it to the LLM as the tool-call response. The rule is about scope, not which activity sets it:
toolCallResult (via a result expression / result variable / output mapping / script result variable).toolCallResult; BPMN variable scoping propagates the value to the sub-process scope when it completes.Ways to set it depending on the activity type:
value="={toolCallResult: response.body}".toolCallResult.<zeebe:output source="=someValue" target="toolCallResult" />.<zeebe:script expression="..." resultVariable="toolCallResult" />.The value can be primitive (string, number) or a complex FEEL context — it'll be serialized to JSON before being sent to the LLM. If toolCallResult is missing or empty when the tool completes, the connector sends a generic "tool succeeded without returning a result" message to the LLM and the next turn degrades. Always set it meaningfully.
Both data.systemPrompt.prompt and data.userPrompt.prompt are FEEL strings — they start with =.
<zeebe:input
source='="You are a customer support agent. Use available tools to look up customers and orders. Escalate to a human only when needed."'
target="data.systemPrompt.prompt" />
<zeebe:input
source='="Customer " + customerId + " (priority: " + priority + ") reports: " + issue'
target="data.userPrompt.prompt" />
="You are ...". The = is mandatory even for plain text.="Customer " + customerId + " reports: " + issue. + coerces scalars to string.=if (is defined(followUpInput)) then followUpInput else initialUserInput. Used when looping back into the agent with user follow-up — see "Response Interaction" below.For long, structured prompts, build the string in a script task upstream and pass it in via a variable.
The tool feedback loop is internal: the agent job worker repeatedly calls the LLM, activates the tools it chose, collects results, and re-prompts until the LLM produces a final response or data.limits.maxModelCalls is reached. You don't model the loop — only the tools.
The agent writes its output to a single context variable named by the connector's Result Variable field — default agent. With multiple agents in the same process, give each a unique result variable name (e.g. mySecondAgent) and re-align the agent-context input field accordingly (mySecondAgent.context) to avoid interference between agents.
Which fields the result context contains depends on the response settings (examples below use the default agent):
agent.responseText — when data.response.format.type=text (the default).agent.responseJson — when format=json, or when format=text with data.response.parseJson=true.agent.context — when data.response.includeAgentContext=true. Needed for the Response Interaction pattern below.Provider × format=json compat. Only OpenAI and Google Vertex AI support data.response.format.type=json natively. For Anthropic, Amazon Bedrock, Azure OpenAI, and OpenAI-compatible providers, use format=text + parseJson=true and read agent.responseJson.
Schema is a FEEL context literal. When format=json, data.response.format.schema is feel: required — write it as ={ type: "object", properties: { ... } }, not a quoted JSON string.
When JSON parsing fails. Behavior differs between the two paths: parseJson=true on format=text omits responseJson and still populates responseText with the raw (unparseable) text — no incident. Native format=json raises an incident with error code FAILED_TO_PARSE_RESPONSE_CONTENT; handle it with an error boundary event if you need graceful fallback.
After the agent produces its final response, you may want a user (or another agent acting as a judge) to review or amend it and bounce it back in. The pattern is to route from the ad-hoc subprocess to a user task that collects followUpInput, then back to the same agent ad-hoc subprocess. The user-prompt FEEL switches between the initial and the follow-up input:
data.userPrompt.prompt = =if (is defined(followUpInput)) then followUpInput else initialUserInput
The agent preserves conversation context across re-entries; see the Sub-process docs for the current context-handling field names, since these have evolved across template versions.
data.limits.maxModelCalls — caps the number of LLM calls per agent execution. Always set it (5–20 is a typical starting range); without a sensible cap a misbehaving prompt can rack up cost. There is no time-based limit.data.memory.contextWindowSize — caps how many prior messages the agent replays to the LLM (default 20). Smaller saves tokens, larger preserves more context.data.memory.storage.type — in-process (default), camunda-document (offload to the Camunda Document store when context grows past variable size limits), or custom. Use the hyphenated form.Non-obvious failure modes the lint loop will not catch.
id, not the name attribute. Use descriptive PascalCase IDs.="...".string(x); + between a string and an un-coerced number fails. Cross-ref camunda-feel § type coercion.in-process, camunda-document, custom. Not camelCase.Run the BPMN lint loop (see camunda-bpmn) before declaring the agent process done:
c8ctl bpmn lint process.bpmn
Lint catches structural BPMN problems but does not validate connector-template inputs. After lint is clean, verify by reading the BPMN:
bpmn:adHocSubProcess with the AI Agent template applied.<bpmn:documentation> element (apply doesn't write it — set it via a direct edit).toolCallResult set in scope.=.data.limits.maxModelCalls is set.{{secrets.*}}, not literal values.For detailed reference material, read from references/:
fromAi() signature, all calling variants (positional, named, enum schemas, optional params, multi-call JSON bodies)npx claudepluginhub camunda/skills --plugin camunda-skillsGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.