The default way to build an agent is now a Conductor Agent — see ../references/agents.md and agent-deploy-and-invoke.md. The server’s compiler emits exactly the shape below (
{name}_init_stateSET_VARIABLE →{name}_loopDO_WHILE {{name}_llm→ guardrails → tool SWITCH → FORK_JOIN_DYNAMIC → merge } →{name}_synth_output), so this page is also how you read a compiled agent’s execution view. Hand-wire only when the compiler cannot express what you need: (a) a custom termination predicate or non-LLM routing between iterations, (b) OpenAIpreviousResponseIdchaining, (c) several models per iteration, (d) the server has agents disabled or you must ship a single JSON file. Say which reason applies (optimization rule F4). This page remains the worked example for ../references/graaljs-gotchas.md and ../references/template-resolution.md.
An LLM-driven agent that thinks, acts, and observes in a loop until it decides it’s done. Built with DO_WHILE wrapping an LLM_CHAT_COMPLETE → SWITCH → tool task inner sequence.
Use this when the answer requires multiple tool calls in sequence — e.g., “look up the customer, then their last order, then refund the matching line item.” A single-shot agent (ai-agent-mcp.md) only plans once; an agent loop replans every iteration with the accumulated tool results in context.
This page also serves as the worked example for everything in ../references/graaljs-gotchas.md and ../references/template-resolution.md. If you are wiring up an agentic loop from scratch, read those two pages first.
init_messages (SET_VARIABLE) ← seed system + user messages
agent_loop (DO_WHILE, graaljs, IIFE, iter cap):
think (LLM_CHAT_COMPLETE, jsonOutput: true) ← emits { action, ... }
route (SWITCH on action):
case "call_tool":
call_tool (HTTP, optional: true) ← external call
build_next_messages (JSON_JQ_TRANSFORM, .current + [...]) ← append to chat history
update_messages (SET_VARIABLE) ← write merged array back
case "answer":
finalize (SET_VARIABLE final_response)
defaultCase: [] ← empty — see below
until think says action == "answer", or iter cap
The whole loop is one workflow task. Each iteration is a durable checkpoint — a crash mid-tool-call resumes from the last completed iteration without replaying earlier LLM calls.
See workflows/ai-agent-loop.json for the full file. The key shape:
{
"name": "autonomous_agent",
"schemaVersion": 2,
"inputParameters": ["question", "max_iterations"],
"variables": { "messages": [], "final_response": "" },
"tasks": [
{
"name": "init_messages",
"taskReferenceName": "init_messages",
"type": "SET_VARIABLE",
"inputParameters": {
"messages": [
{"role": "system", "message": "You are an agent. Tools available: get_weather(city). Respond ONLY as JSON: {\"action\": \"call_tool\", \"tool\": \"get_weather\", \"args\": {...}} OR {\"action\": \"answer\", \"answer\": \"...\"}."},
{"role": "user", "message": "${workflow.input.question}"}
]
}
},
{
"name": "agent_loop",
"taskReferenceName": "agent_loop",
"type": "DO_WHILE",
"evaluatorType": "graaljs",
"loopCondition": "(function(){ return $.agent_loop['iteration'] < $.max_iterations && $.final_response === ''; })();",
"inputParameters": {
"agent_loop": "${agent_loop.output}",
"max_iterations": "${workflow.input.max_iterations}",
"final_response": "${workflow.variables.final_response}"
},
"loopOver": [
{
"name": "think",
"taskReferenceName": "think",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o-mini",
"messages": "${workflow.variables.messages}",
"temperature": 0.1,
"maxTokens": 500,
"jsonOutput": true
}
},
{
"name": "route",
"taskReferenceName": "route",
"type": "SWITCH",
"evaluatorType": "graaljs",
"expression": "$.action",
"inputParameters": { "action": "${think.output.result.action}" },
"decisionCases": {
"call_tool": [
{
"name": "call_weather",
"taskReferenceName": "call_weather",
"type": "HTTP",
"optional": true,
"inputParameters": {
"http_request": {
"uri": "https://wttr.in/${think.output.result.args.city}?format=j1",
"method": "GET"
}
}
},
{
"name": "build_next_messages",
"taskReferenceName": "build_next_messages",
"type": "JSON_JQ_TRANSFORM",
"inputParameters": {
"current": "${workflow.variables.messages}",
"tool_result": "${call_weather.output.response.body}",
"queryExpression": ".current + [{\"role\": \"assistant\", \"message\": \"Called get_weather.\"}, {\"role\": \"user\", \"message\": (\"Tool result: \" + (.tool_result | tojson))}]"
}
},
{
"name": "update_messages",
"taskReferenceName": "update_messages",
"type": "SET_VARIABLE",
"inputParameters": {
"messages": "${build_next_messages.output.result}"
}
}
],
"answer": [
{
"name": "finalize",
"taskReferenceName": "finalize",
"type": "SET_VARIABLE",
"inputParameters": {
"final_response": "${think.output.result.answer}"
}
}
]
},
"defaultCase": []
}
]
}
],
"outputParameters": {
"answer": "${workflow.variables.final_response}",
"iterations": "${agent_loop.output.iteration}"
},
"timeoutSeconds": 600,
"timeoutPolicy": "TIME_OUT_WF"
}
Each line exists because the obvious alternative fails subtly (the agent compiler makes the same choices for you).
evaluatorType: "graaljs" on the DO_WHILE. The "javascript" alias works for INLINE but has failed for loopCondition on some cluster versions; set "graaljs" explicitly.loopCondition with an iteration cap: (function(){ return $.agent_loop['iteration'] < $.max_iterations && $.final_response === ''; })(); — an IIFE returns a clean boolean (the if {true} else {false} statement form is fragile); the cap is mandatory (rule B5); $.final_response === '' exits the moment finalize fires.inputParameters wires for the condition: "agent_loop": "${agent_loop.output}" (the canonical lazy self-reference exposing iteration), plus max_iterations and final_response plumbed in explicitly — $.workflow.input.* / $.workflow.variables.* are not in scope inside loopCondition (TypeError: Cannot read property "input" from undefined).LLM_CHAT_COMPLETE with jsonOutput: true makes output.result a parsed object so the SWITCH can branch on action. Caveats: the strict Jackson parse fails on markdown fences (Claude emits them — prefer provider-native structured output or jsonOutput: false + substring extraction); the messages schema is {role, message} not {role, content} (Content must not be null for SYSTEM or USER messages); message must be a string — an object is Java-toStringed into {key=value} garbage, so stringify tool results with JQ first (step 6).defaultCase: []. A default that calls finalize would overwrite final_response with garbage whenever the model emits an unknown action; an empty default skips the junk and the loop tries again.JSON_JQ_TRANSFORM stringifies and accumulates in one step: .current + [{"role":"assistant","message":"Called get_weather."}, {"role":"user","message":("Tool result: " + (.tool_result | tojson))}]. tojson avoids the Java-Map proxy trap (JSON.stringify on a proxy returns "{}", String(...) returns {k=v}); .current + [...] appends to the history instead of replacing it (a fresh two-entry SET_VARIABLE would wipe prior turns). The JQ input is the whole inputParameters map minus queryExpression — see ../references/graaljs-gotchas.md Rule 3 and ../references/template-resolution.md Pitfall 2.optional: true (+ retryCount on its task def) keeps the loop alive when the service flakes; for finer control branch route on call_weather.output.response.statusCode and feed the model a “service unavailable” message.workflow.variables (JQ-concat then SET_VARIABLE): the model needs the full conversation every iteration, ${workflow.variables.messages} is unambiguous from inside the loop, and it survives a restart. Variables are workflow-global — keep only cross-iteration state there.conductor workflow create examples/workflows/ai-agent-loop.json
conductor workflow start -w autonomous_agent --sync -i '{"question":"What is the weather in San Francisco?","max_iterations":5}'
previousResponseId chainingCommitted to OpenAI / Azure OpenAI? Chain turns through the Responses API instead of re-sending the accumulated messages every iteration: keep previous_response_id in workflow.variables (empty at first), pass "previousResponseId": "${workflow.variables.previous_response_id}" into think, SET_VARIABLE it to ${think.output.responseId} after each turn, and send only the new content in messages. Savings scale with how much context you would otherwise re-send. Keep the message-accumulation scaffold for portable workflows (mixed providers, proxies without the Responses API, runs that outlive OpenAI’s ~30-day response retention). Full pattern and caveats: llm-chaining.md.
Without history accumulation the loop collapses to LLM_CHAT_COMPLETE (prompt carries Previous results: ${agent_loop.output}) → SWITCH → one CALL_MCP_TOOL or NOOP, defaultCase: []. Enough when the model reliably emits {action: "answer"}; use the full scaffold for durable history, retries, or malformed-output handling.
Cap iterations ($.iteration < N and the early-exit predicate) or a buggy “not done yet” reply spins forever; set timeoutSeconds + timeoutPolicy: TIME_OUT_WF (a slow tool call can still hang a capped loop); cost = iterations × maxTokens — the loop has no token budget of its own; no secrets in workflow.input (use ${workflow.secrets.X} or worker env — inputs show in the execution view); keep defaultCase empty.
LLM_CHAT_COMPLETE enables provider-native tools with a boolean — webSearch, codeInterpreter, fileSearchVectorStoreIds (OpenAI), googleSearchRetrieval (Gemini) — so a “search and run code” loop needs no MCP server; add tools: [...] for custom functions. Matrix: llm-chat.md.
| Use single-shot (ai-agent-mcp.md) | Use a Conductor Agent (agents.md) | Use this hand-wired loop |
|---|---|---|
| Answer always needs exactly one tool call | Unknown number of tool calls; tools are functions in your code; approval gates, guardrails, multi-agent, streaming, reuse by name | Same, but you need a custom loop condition, non-LLM steps between turns, previousResponseId, or JSON-only |
| Model constrained to ONE action; latency matters | Native function calling; budget tolerates N round trips (max_turns) |
JSON {action} routing convention you own and maintain |