Skip to main content

Flows

A flow is a declarative orchestration graph for an app: nodes (agents, tools, decisions, gates, ...) chained by conditional edges. When a flow: block is present at the top level of the YAML, the runtime drives the app along the explicit graph instead of relying on the agents' system prompts to coordinate themselves via Agent tool calls.

The schema is enforced at compile time (unknown keys forbidden on node types). Cross-references (node ids, agent ids, tool names, reachability, cycles) are validated when the app is compiled.

Why flows?​

Two coordination paradigms exist in Digitorn:

  • Implicit - the coordinator agent decides who to call next via the Agent tool (agent_spawn module). Flexible but every decision costs an LLM round-trip.
  • Explicit (flow) - the graph IS the orchestration. Agents are invoked by the flow engine; routing is decided by literal expressions or human approval, not by an LLM call. Deterministic, cheap, easier to audit.

Use a flow when:

  • You can describe the workflow as a graph (triage → specialist → approval → output).
  • The routing rules are deterministic (regex on user input, simple boolean expressions).
  • You want a human gate at a specific point.
  • You want fan-out / fan-in with a known join policy.

Stick to the implicit pattern when:

  • The workflow is genuinely free-form (open-ended chat).
  • The coordinator needs to make judgment calls about who's the right specialist for a given message.

YAML structure​

flow: is a top-level block in v2 (AppDefinition.flow) - put it there, side-by-side with the other top-level blocks, not under runtime:. A graph declared under runtime.flow is rejected at compile time (runtime.flow is never read by the flow engine) - nothing reaches the flow engine from there.

yaml
flow:
id: support_main # required, unique within the app
entry: triage # required, must be a declared node id
description: "Support triage with refund gate"
max_iterations: 100 # 0 = no cap (acyclic only); REQUIRED ≥ 1 if any cycle
nodes:
- id: triage
type: agent
agent: triage_bot
params:
user_message: "{{event.payload.message}}"
routes:
- { when: "category == 'refund'", to: refund }
- { when: "category == 'tech'", to: tech_support }
- { when: "default", to: end }
on_error:
- { match: "TimeoutError", to: triage_retry }
- { default: true, to: end }

- id: refund
type: agent
agent: refund_specialist
routes:
- { to: gate }

- id: gate
type: approval
message: "Confirm refund request?"
choices: [approve, reject]
routes:
- { when: "approvals.gate == 'approve'", to: send_refund }
- { when: "default", to: end }

- id: send_refund
type: tool
tool: http.request
params:
method: POST
url: "{{secret.REFUND_WEBHOOK}}"
body: '{"to":"{{event.payload.from}}","body":"Refund approved"}'
routes:
- { to: end }

- id: tech_support
type: agent
agent: tech_specialist
routes:
- { to: end }

FlowConfig fields​

The top-level flow: block.

FieldTypeRequiredDescription
idstring (min 1)yesFlow identifier, unique within the app.
entrystring (min 1)yesStarting node id. Must be a declared node.
descriptionstringnoFree-form summary.
max_iterationsint ≥ 0conditionalPer-flow cap on total node visits. 0 = no cap (only valid for acyclic flows). Required ≥ 1 when the graph has any cycle to prevent infinite loops at runtime.
nodeslist[FlowNode] (min 1)yesNodes that compose the graph.

Compiling a flow checks:

  • Every routes[].to and on_error[].to references either a declared node or the literal sentinel "end".
  • Every agent node's agent references a declared agents[].id.
  • Every tool node's tool is a real module.action FQN reachable from the declared modules.
  • If any cycle is reachable, either that node's own max_iterations or the flow-level max_iterations is ≥ 1 - otherwise it's a compile error.

A node unreachable from entry currently compiles without error; nothing walks the graph to flag orphans.

Node types​

Six discriminated variants. Each inherits four common fields:

Common fieldDescription
idUnique within the flow.
descriptionSurfaced as the canvas tooltip.
routesOutgoing edges, tried in order. On a decision node the first one whose when equals the expression's value wins, and a default: true entry is kept as a fallback no matter where it sits. On every other node type, evaluation stops at the first entry that matches at all - including a catch-all - so on those, default: true must be the last entry or later conditions never get a chance.
on_errorError-handling edges. Regex-matched entries are checked in order; a default: true entry is remembered as the fallback wherever it sits and only used if nothing else matched.

agent - run a declared agent​

FieldRequiredDescription
type: agentyesDiscriminator.
agentyesagents[].id to execute.
params.task (alias params.user_message)noTemplated message passed to the agent. Empty params falls back to the previous step's text, then to the inbound event message.
params.memory_seednoTemplated; seeds the agent's memory for this run.

The agent runs for exactly one turn (system prompt + user input → response). The response is added to the flow context under <node_id>.output so downstream routes can read it.

tool - direct tool invocation, no LLM​

FieldRequiredDescription
type: toolyesDiscriminator.
toolyesFQN, e.g. web.search or http.request.
paramsnoParameters; supports {{templates}}.

The tool's response lands in the flow context under <node_id>.result.

parallel - fan-out, join, continue​

FieldRequiredDescription
type: parallelyesDiscriminator.
branchesyesList of { to: <node_id> } entries (at least 1 - an empty list is rejected as a step that would report success without running anything). Each to is the head of a concurrent branch.
joinnoJoin policy (default = wait for all).

Join policy fields:

FieldDefaultDescription
type"all"all (wait for every branch), any / first (continue on first complete), count / min (wait for min branches).
min0How many branches to wait for when type is count or min. Below 1 it's silently treated as 1; above the branch count it's clamped to the branch count.
timeout60.0 (seconds, > 0)Wall-clock cap. Branches still running when it elapses are cancelled and treated as failed.
yaml
- id: gather
type: parallel
branches:
- { to: search_web }
- { to: search_db }
- { to: search_docs }
join:
type: count # continue when 2 of 3 branches return
min: 2
timeout: 15.0
routes:
- { to: synthesize }

approval - human-in-the-loop gate​

FieldRequiredDescription
type: approvalyesDiscriminator.
messageyes (min 1)Question shown to the user. Rendered through {{...}} - see Flow context.
choicesno (default ["approve", "reject"], min 2)Selectable answers.

The user's pick is recorded as approvals.<node_id> = <choice> in the flow context. Downstream routes branch on it:

yaml
- id: gate
type: approval
message: "Approve refund of $1200?"
choices: [approve, reject, escalate]
routes:
- { when: "approvals.gate == 'approve'", to: send_refund }
- { when: "approvals.gate == 'escalate'", to: human_review }
- { when: "default", to: end }

The flow pauses on this node - the runtime broadcasts the approval request via the approval queue, and resumes when the user picks one of the choices.

decision - pure routing, no LLM, no tool​

FieldRequiredDescription
type: decisionyesDiscriminator.
expryes (min 1)Expression evaluated against the flow context; routes match on the result.
yaml
- id: route_by_priority
type: decision
expr: "ticket.priority"
routes:
- { when: "p0", to: emergency_responder }
- { when: "p1", to: senior_specialist }
- { when: "default", to: standard_queue }

terminal - end of a flow path​

FieldRequiredDescription
type: terminalyesDiscriminator.
outputnoFinal output payload returned by this path (default {}). Rendered through {{...}} - see Flow context. Empty falls back to the previous step's text.

Terminal nodes typically have empty routes (the flow stops). If they do declare routes they continue as a sub-flow continuation point - the runtime treats the path as ended for the caller's purposes regardless.

The literal "end" sentinel is also accepted in routes[].to to terminate a path without declaring an explicit terminal node:

yaml
- id: triage
type: agent
agent: triage
routes:
- { when: "category == 'refund'", to: refund }
- { when: "default", to: end }

Routes and edges​

FlowRoute​

The standard outgoing edge.

FieldDefaultDescription
when"default"Condition expression or the sentinel "default".
torequiredTarget node id, or "end".

On a decision node the first when that equals the node's expression value wins, and a default: true (or when: "default", or an empty when) entry is kept as the fallback wherever it sits. On every other node type, entries are tried top-to-bottom and evaluation stops at the first one that matches at all - including a catch-all - so there default: true needs to be the last entry, or later conditions are never reached.

Routes are evaluated top-to-bottom in declaration order. The expression syntax is intentionally NOT validated at the schema layer - it's validated by the runtime expression engine when the flow runs.

FlowOnErrorRoute​

Error-handling edge.

FieldDefaultDescription
matchnullRegex matched against the runtime error type or message.
defaultfalseCatch-all clause, kept as the fallback wherever it sits among on_error entries; used only if no match matched.
torequiredTarget node when this clause matches.
yaml
- id: call_api
type: tool
tool: http.request
# params should include method, e.g. method: GET

params: { url: "..." }
on_error:
- { match: "TimeoutError", to: retry_with_backoff }
- { match: "AuthenticationError", to: refresh_credentials }
- { default: true, to: error_log }
routes:
- { to: parse_response }

Reachability and cycles​

Compiling a flow verifies:

  • Every to target exists (declared node or "end").
  • If the graph has a cycle, max_iterations (flow-level or on the node that closes the cycle) must be ≥ 1. Acyclic graphs may keep max_iterations: 0 (no cap).

A node that nothing routes to still compiles - reachability from entry isn't checked.

The runtime enforces max_iterations per session - when the visit counter hits the cap, the current path is forced to "end" and an event is logged.

Flow context - what nodes can read​

Every node sees a small dict-like context populated as the flow progresses:

PathNotes
event.*The triggering event: event.payload.*, event.event_id, event.provider, event.adapter, event.message, event.timestamp.
<node_id>.outputAn agent or transform step's output, by node id.
<node_id>.resultA tool step's result, by node id.
<node_id>.<field>A specific field from that step's JSON output/result, if it parsed as an object.
secret.<name>Same secret resolver as compile-time {{secret.x}}.
approvals.<node_id>The choice picked at that approval node.
error.*error.node / error.type / error.message, set only while routing through an on_error clause.
a bare field nameFalls back to a field on the last agent step's parsed JSON output (no node id needed).

Append | json to dump a value as JSON instead of its plain-text form, e.g. {{ticket | json}}.

Every routes[].when / expr expression, flow.approval's message, flow.terminal / flow.end_chat's output, and every agent / tool / transform step's params can reference these via {{...}}. system_prompt (agent or mode override) is a separate, compile-time-only resolver - it never sees this flow context, see App config.

Compile-time guarantees​

Compiling a flow catches these common authoring mistakes before deploy:

  • Unknown agent reference → unknown agent 'foo' on flow node 'bar'.
  • Unknown tool FQN → unknown tool 'module.bad' on flow node 'X'.
  • Dangling routes[].to / on_error[].to → unknown target 'Y' on flow node 'X'.
  • Cyclic flow without a cap → flow has cycles but max_iterations=0.
  • parallel step with zero branches → rejected as a step that would report success without running anything.

A node that no route ever points to still compiles - nothing walks the graph from entry to flag it as unreachable.

Cross-references​