How-to recipes
Task-oriented answers. Each recipe is a self-contained pattern you can paste and adapt. For the why behind them, see the Language Guide; for exact fields, the and .
Recipes include a flow diagram whenever the pattern changes the workflow graph. Options such as
retry and timeout stay code-focused because they change how one node runs, not the shape of the
graph.
- Get structured output from an agent
- Branch on an agent's decision
- Retry a flaky step with backoff
- Put a deadline on a step
- Run steps in parallel and merge
- Take the first of several results
- Process every item in a list
- Loop until "good enough"
- Pause for human approval
- Wait a fixed amount of time
- Continue an agent's conversation in the next step
- Call a tool and use its result
- Compose smaller workflows
- Coordinate two runs with an event
- Reshape data without an LLM call
Get structured output from an agent
Add schema and read the typed fields back via .structured.*. Define the schema by name at
file scope (before the workflow block), then reference it from the node:
schema Triage """
{ "type": "object", "properties": { "category": { "type": "string" } }, "required": ["category"] }
"""
classify = agent triager:
"Classify: {{ input.message }}"
schema Triage
classify -> return {
category: classify.structured.category
}Triage is a file-level schema definition in the same .axg file - not an external registry.
For a one-off, inline the schema on the node instead:
classify = agent triager {
prompt "Classify: {{ input.message }}"
schema """{ "type": "object", "properties": { "category": { "type": "string" } }, "required": ["category"] }"""
}Branch on an agent's decision
Use a when … / else edge; route to exactly one arm. Each arm must reach its own return.
classify = agent triager:
"Classify: {{ input.message }}"
schema Triage
classify -> billing when classify.structured.category == "billing"
classify -> general else
billing = agent billing_agent:
"Handle billing: {{ classify.response }}"
context handoff
general = agent general_agent:
"Handle general: {{ classify.response }}"
context handoff
billing -> return {
via: "billing",
reply: billing.response
}
general -> return {
via: "general",
reply: general.response
}For more than two paths, chain another when/else off the else arm.
Retry a flaky step with backoff
retry <n> retries on failure; after <dur> waits between attempts.
fetch = agent scraper:
"Fetch and extract {{ input.url }}"
retry 3 after 30s
timeout 1mInside a loop, put retry on the body node, not the loop.
Keep going after a step fails
retry covers a flaky step. When one fails for good, on error routes somewhere
else instead of ending the run.
check = tool sanctions_api {
name: input.name
}
retry 3 after 5s
timeout 30s
check -> score when truthy(check.result)
check -> reject else
check -> manual on error
manual = agent reviewer:
"The sanctions check could not complete: {{ check.error }}. Decide manually."
manual -> return { status: "manual_review", note: manual.response }The route is taken only after retries are exhausted, so retry stays your first
line of defence. check.error carries the reason, and a timeout routes the same way
as any other failure.
One route per node. The handler is a diversion. It cannot restart the failed step, so
anything that depended on check stays unrun.
Put a deadline on a step
timeout <dur> fails the node if it runs too long (then retries if configured).
slow = agent researcher:
"Deep-research {{ input.topic }}"
timeout 10m
retry 1Run steps in parallel and merge
Leave out the edge between independent nodes; raise parallel max; fan in with join all.
parallel max 2
a = agent researcher:
"Pros of {{ input.topic }}."
b = agent researcher:
"Cons of {{ input.topic }}."
merge = join all [a, b]
merge -> return {
pros: a.response,
cons: b.response
}Take the first of several results
join any completes on the first dep to finish and cancels the rest.
fast = join any [provider_a, provider_b, provider_c]
fast -> return {
answer: fast.output
}Need "at least N of M"? Use join quorum 2 [a, b, c].
Process every item in a list
map over runs the body once per element, concurrently, and collects ordered results.
summaries = map over input.docs {
agent summarizer:
"Summarize: {{ $.item }}"
}
summaries -> return {
all: "{{ $.nodes.summaries.output }}"
}Concurrency obeys parallel max; bounded to 256 items; any item failing fails the map.
Loop until "good enough"
loop max <n> until <expr> from <seed> repeats a step, seeing the prior result as prev.
seed = agent writer:
"Draft about {{ input.topic }}."
draft = loop max 5 until prev.score >= 8 from seed {
agent critic:
"Improve and rate (return {text, score}): {{ prev.text }}"
}
seed -> draft
draft -> return {
final: draft.text
}Drop until to always run the full max.
Pause for human approval
suspend parks the run; resume it over HTTP with a payload that becomes the node's output.
draft = agent drafter:
"Draft a reply to {{ input.request }}"
gate = suspend: "Approve before sending?"
draft -> gate -> return {
approved: gate.ok,
text: draft.response
}Resume:
curl -sX POST "$AXL_HOST/api/v1/workflow/flow/$RUN_ID/resume" \
-H "Authorization: Bearer $TOKEN" \
-d '{ "node": "gate", "data": { "ok": true } }'Wait a fixed amount of time
delay parks the run for a wall-clock duration (durable - survives restarts).
a -> cooldown
cooldown = delay 15m
cooldown -> bContinue an agent's conversation in the next step
context handoff makes a node continue the conversation of the node it depends on, instead of
starting fresh.
research -> draft
draft = agent writer:
"Now write the article."
context handoffUse context workflow to share one conversation across the whole run.
Call a tool and use its result
A tool node's object is the tool's arguments; its result is the node's output.
fetch = tool web_fetch {
url: input.url,
depth: "basic"
}
summarize = agent writer:
"Summarize this page: {{ fetch.result }}"
fetch -> summarize
summarize -> return {
summary: summarize.response
}Read-only tools run inline; write tools run durably and won't double-fire on retry.
Compose smaller workflows
A workflow node runs another registered workflow as a child. Register the child first.
call = workflow summarize_doc {
doc: input.doc
}
call -> return {
result: "{{ $.nodes.call.output }}"
}The child is pinned by content hash, so re-registering it mid-run can't swap it under a live parent.
Coordinate two runs with an event
One run emits; another waits. Events are scoped to the run's owner.
// in run A
notify = event emit "payment.confirmed" {
order: input.order_id
}
// in run B
gate = event wait "payment.confirmed"
gate -> return {
paid: "{{ $.nodes.gate.output }}"
}A wait only matches events fired after it arms; bound it with timeout if it might never come.
Reshape data without an LLM call
transform assembles/renames data into its own output - free, no side effects.
shape = transform {
title: research.structured.title,
body: writer.response
}
shape -> return {
doc: shape.output
}Next
- Language guide - the reasoning behind these patterns.
- - exact fields per node kind.
- Workflow operations - running what you just wrote.