Workflows

Language guide

AXG (AXL Graph) is a small declarative language for describing a workflow as a graph. This guide explains how it works conceptually. For exact syntax, see the and .

A workflow is a graph

You declare nodes (the steps) and edges (the dependencies between them). The engine computes the order: a node runs once all the nodes it depends on have finished, and independent nodes run in parallel (up to a concurrency cap).

axg 1
workflow example {
  input topic

  a = agent researcher:
    "Research {{ input.topic }}."  // a node
  b = agent writer:
    "Write it up: {{ a.response }}"  // another node

  a -> b  // an edge: b depends on a
  b -> return {
    article: b.response
  }  // finish
}

The graph must be a DAG - no cycles - and at least one path must reach a return. Both are checked when you submit; you can't run a graph that could deadlock.

File structure

axg 1                       // version header — required, must be `axg 1`

schema <Name> """{ … }"""   // optional: named JSON Schema(s), at file scope before the workflow block

workflow <name> {
  input <name>              // zero or more inputs (read as input.<name>)
  parallel max <n>          // optional: concurrency cap for this workflow

  <id> = <node>            // node declarations (any order)
  <id> -> <id>             // edges (any order)
}
  • Node ids are [A-Za-z0-9_-]; they can't start with _axg_ (reserved for compiler-generated nodes).
  • A schema <Name> """{ … }""" definition may appear at file scope, before the workflow block; agent/model nodes then reference it by name (schema <Name>). These are definitions in the same .axg file, not an external registry.
  • // and /* … */ are comments.
  • Declaration order doesn't matter - the graph is defined by the edges, not the text order.

Nodes

Every node has a kind (what it does) and a unique id (how other nodes refer to it). The kinds:

KindOne-liner
agentrun a registered agent with a prompt
modelone agentless LLM call
toolinvoke a registered tool
branchroute to one of two paths based on a condition
transformreshape data inline (no side effects)
returnfinish the run with an output
delaypause for a wall-clock duration
suspendpause until a human resumes it
looprepeat a step to a cap or until a condition
maprun a step once per item in a collection
joinwait for several upstream nodes to finish
eventemit or wait on a named event
workflowrun another registered workflow as a child

Most nodes have a short form (id = agent key: "prompt" attr …) and some have a block form (id = agent key { prompt "…" attr … }). Full details in the .

Passing data: references

Nodes don't share variables - they read each other's outputs by reference. A reference is a dotted path:

ReferenceReads
input.topicthe run input field topic
input.config.regiona nested input field
<node>.responsean agent/model node's text answer
<node>.structured.fielda node's structured (schema'd) output field
<node>.<field>any field of a node's output (e.g. a tool's <node>.result)
prev.x(inside a loop body) the previous iteration's output
item(inside a map body) the current collection element

Two rules apply, and only one of them is enforced by the compiler:

  1. The node must exist. A typo'd node id is a compile error, not a silent empty value. This is enforced at submit.
  2. You read a node's output - node.response, node.structured.*, or node.<field> - or its error on an on error route. Nothing else about a node is readable from the graph.

The field is not checked, and cannot be: a node's output shape is decided at runtime by the agent's schema or the tool's result. So a.status compiles - the bare-field sugar reads it as an output field named status - and then resolves to nothing when the node runs. Run-state lives in the run document, not in the graph.

Under the hood these compile to canonical $. JSON paths (input.topic$.input.topic, a.response$.nodes.a.output.response). You can write the $.-form directly too; both are valid.

Templates

Inside any string, {{ <reference> }} is a template placeholder spliced at runtime:

summary = agent summarizer:
  "Summarize {{ input.topic }} using {{ research.response }}."

If a placeholder can't resolve when the node runs (e.g. it points at data that isn't there), the node fails loudly rather than sending a literal {{ … }} to the agent. Template references are validated at submit just like everything else.

Conditions: the branch expression language

branch (with when …) and loop (with until …) take a boolean expression:

route -> approve when truthy(review.structured.passed) && score >= 8
route -> reject else
FormTrue when
truthy(ref)present and non-empty / non-zero / true
exists(ref)present at all (including an explicit null)
ref == valuestrictly equal (11.0"1")
ref >= n (>, <, <=)numeric comparison
a && b, a || b, !a / not a, ( … )boolean composition

When something fails

A node that fails takes down the run, which is usually what you want: you find out loudly. Three things change that, in order of how much you should reach for them.

Retry handles a flaky step. The node runs up to n times in total.

  check = tool sanctions_api {
    name: input.name
  }
    retry 3 after 5s
    timeout 30s

retry 3 is three attempts in total - the first try and two more, five seconds apart - not three retries on top of the first. retry 1 therefore means no retry at all.

on error handles a step that failed for good. Once retries are exhausted, the run continues down a route you choose instead of ending:

  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."

check.error is the failure reason. A timeout is a failure, so it routes the same way. Each node takes at most one on error route, and the handler is a diversion: it cannot restart the failed node or stand in for its output, so anything that depended on check stays unrun.

A handled failure is not a failed run. A run that took its on error route and reached a return finishes completed, because it completed the path you authored - and in axl workflow list it looks exactly like a run that never went wrong. Alerting on status != completed will therefore never tell you the vendor was down.

What records it is the run document: the failed node keeps its own failed status and its error, so axl workflow status <run_id> shows the diversion even though the run is green. If a diversion is something you want to hear about, say so in the handler - have it emit, or return an output field your caller checks:

  manual -> return { status: "manual_review", reason: check.error }

join any handles the case where you have several ways to get a result and only need one. A failed branch does not sink a join whose threshold is still reachable.

Without any of these, a failed node fails the run and its error lands on the run's output. That is the default and it is a reasonable one - route only where you have something better to do than stop.

The lifecycle: compile → submit → run

  1. Compile. Your .axg is parsed and lowered to a canonical JSON WorkflowDefinition, then validated (references resolve, the graph is acyclic, every branch reaches a return, sizes and durations are within bounds). axl workflow check runs exactly this, locally.
  2. Submit. You POST the source (or the JSON). The engine creates a durable run, returns a run_id immediately, and queues it.
  3. Run. A background consumer advances the run in small steps ("pulses"), launching nodes, collecting their outputs, and persisting state after every step. The run survives restarts, and multiple server instances can share the work.
  4. Finish. When a return node runs, the run is completed and its output is the return body. A node that exhausts its retries (or whose run is cancelled) ends the run failed/cancelled.

You never manage any of step 3 - you submit, then poll or stream.

What you get for free

  • Durability. Run state lives in Redis; a crash mid-run resumes from the last committed step.
  • Exactly-once side effects (common case). Write tools and child launches are guarded so a retry/restart doesn't double-fire them.
  • Retries & timeouts. Per-node retry/timeout; a node past its deadline fails (and retries if configured).
  • Fan-out & fan-in. map, parallel, and join express concurrency without you wiring threads.
  • Fail-fast authoring. Bad references, cycles, unreachable returns, oversized values, and impossible joins are all rejected at submit.

Next

  • Tutorials - apply all of this to build a pipeline.
  • / - the exact syntax.

On this page