Authoring Flows
A complete guide to writing flows (workflows / pipelines) for the AI Flow runtime.
Who this is for. Anyone authoring a flow definition — whether you run it locally with the
kis flowCLI or deploy it toaiflow.svcas a tenant workflow. No Go knowledge required.
1. What a flow is
Section titled “1. What a flow is”A flow is a directed graph of tasks. Each task does one thing — run a shell command,
call an HTTP endpoint, make a decision, iterate over a list, wait for a signal. Tasks are
connected by routing (next: for success, error: for failure), and they share a single
bag of data called the context.
The runtime detects the kind of each node from which key you use — you never write a
nodetype: field. shell: makes it a shell task; choice: makes it a decision; map: makes
it a parallel fan-out. That is the single most important idea in this guide.
Flows are executed by the flow engine. When run under aiflow.svc, each Task node is dispatched to a
remote agent chosen by the agent fabric based on tags, model, GPU/CPU, and capacity.
2. Your first flow
Section titled “2. Your first flow”name: hello-worldtasks: - name: greet print: "Hello, World!"Run it:
kis flow -f hello-world.yamlA slightly richer one that uses variables, a shell task, and routing:
name: build-and-greetvars: who: Worldtasks: - name: build shell: | echo "building..." next: go: greet
- name: greet print: "Done, {{who}}!"Override a variable at run time with -v:
kis flow -f build-and-greet.yaml -v who=Team3. File formats
Section titled “3. File formats”Three shapes are accepted. All three parse to the same thing.
(a) Bare — the most common
Section titled “(a) Bare — the most common”Workflow keys sit at the document root:
name: my-flowversion: "1.0.0"tasks: - name: step-one print: "hi"(b) Single-flow with a flow: wrapper
Section titled “(b) Single-flow with a flow: wrapper”Self-describing; everything lives under flow::
flow: name: single-flow-example version: "1.0.0" vars: greeting: Hello tasks: - name: show print: "{{greeting}}"(c) Multi-flow with a flows: list
Section titled “(c) Multi-flow with a flows: list”Several definitions in one file. Select which to run with -n <name>. Also the idiomatic way
to keep a parent flow next to the child it calls via SubWorkflow:
flows: - name: build tasks: - name: compile print: "building"
- name: deploy vars: { env: staging } tasks: - name: ship print: "deploying to {{env}}"kis flow -f flows.yaml # lists the flowskis flow -f flows.yaml -n deploy # runs the "deploy" flow4. Workflow-level keys
Section titled “4. Workflow-level keys”| Key | Type | Meaning |
|---|---|---|
id | string | Definition ID (the store key). Auto-generated (ULID) for script flows and inline HTTP defs; set it explicitly for stable references. |
name | string | Human name; usable as a lookup handle. |
description | string | Free text. |
version | string | Defaults to "1.0.0". |
type | string | Which task registry / registry type to use. |
vars | map | Workflow variables → seed the initial context. Overridable with -v key=value. |
starttask | string | First node to run (defaults to the first task in the list). |
active | bool | Default true. |
loglevel | string | debug / info / … |
user | string | OS user for shell tasks. |
workingdirectory | string | CWD for shell tasks (~ expands to home). |
continueonerror | bool | Default true for DAG, false for array. |
imports | map | Alias map: bare name → URI, substituted into file/src/path/ref fields at parse time. |
tables | map | Named datasets for data-driven tasks (see §15). |
affinity | map | Agent affinity policy (see §11). |
access | map/string | Workflow-level authorization (see §14). |
pipeline / list / states | — | Execution-mode selectors (see below). |
Execution mode is chosen by these keys:
| You write | You get |
|---|---|
(nothing special — routing via next:/error:) | DAG (default) |
list: true | Array (sequential) |
states: [...] | Stateflow (state machine) |
pipeline: true | DataPipeline (source/sink streaming) |
5. The anatomy of a task
Section titled “5. The anatomy of a task”Every entry under tasks: is one node. The keys you can use:
- name: deploy # REQUIRED, unique within the flow type: shell # optional explicit task type… shell: "make deploy" # …or use the task type AS the key (shorthand) vars: { region: us-west-2 } # task-scoped variables check: "env === 'prod'" # precondition — skip the node if false selectors: { gpu: true } # which agent should run it group: build # co-locate with other tasks in this group retry: { max_attempts: 3 } # retry policy timeout: 5m # execution timeout final: false # force-run even after errors; mark terminal input: [raw] # named data-pipe input ports (pipeline mode) output: [clean] # named data-pipe output ports (pipeline mode) before: [ ... ] # pre-hooks after: [ ... ] # post-hooks access: "user.role == 'admin'" next: { go: verify } # success routing error: { go: rollback } # failure routingThe shorthand rule
Section titled “The shorthand rule”If a task has no type:, the first non-reserved key becomes both the task type and its
body. These two are identical:
- name: build type: shell shell: "go build ./..."- name: build shell: "go build ./..."shell: → task type shell, execute body "go build ./...". Likewise print:, httpclient:,
git:, and any other registered task type (see §19).
6. Routing: next and error
Section titled “6. Routing: next and error”Edges are next: (on success) and error: (on failure). Each is an “on” block:
next: go: default-target # unconditional target node (REQUIRED) set: { flag: true } # inject vars into context before routing when: # optional ordered conditions — first match wins - code: "status === 'ok'" language: js # optional, default "js" go: ok-target set: { verified: true } - code: "status === 'retry'" go: retry-targetRules:
go:is required — on the block and on everywhen:entry.when:conditions are evaluated in order; the first match routes to itsgo:and applies itsset:. If none match, the block’s top-levelgo:is used.- A Task node with neither
next:norerror:is automatically final (terminal). set:values are injected into the context at the moment of the transition.
Choice nodes route differently — they use
rules[].next/default:inside thechoice:block, not the node’snext:/error:. See §7.3.
7. Node type reference
Section titled “7. Node type reference”7.1 Task (the default)
Section titled “7.1 Task (the default)”A unit of work dispatched to a worker/agent.
- name: run-tests shell: | go test ./... next: { go: report } error: { go: notify-failure }Anything that isn’t one of the control keys below is a Task. The task type must resolve to a registered task (§19).
7.2 Assign
Section titled “7.2 Assign”Inject or reshape variables in the context. Runs inline (no worker). pass: is a backward-compat alias.
- name: set-defaults assign: set: region: us-west-2 environment: staging features: dark_mode: true next: { go: next-step }Use it for defaults, computed values, or reshaping data between tasks.
7.3 Choice
Section titled “7.3 Choice”Conditional routing. Rules are JavaScript expressions evaluated against the context; first
match wins; falls through to default:.
- name: route-by-status choice: rules: - condition: "status === 'approved' && priority === 'high'" next: fast-track - condition: "status === 'approved'" next: standard-approval - condition: "status === 'rejected'" next: handle-rejection default: needs-reviewChoice uses
rules[].nextanddefault:— not the node’s ownnext:/error:. If no rule matches and there is nodefault:, the workflow errors.
7.4 ForEach
Section titled “7.4 ForEach”Sequential iteration — one item at a time.
- name: health-check-each foreach: items_path: servers # dot-path to a list already in context task: check-server # the task to run per item next: { go: report }
- name: check-server shell: 'echo "checking {{_index}}: {{_item.host}}:{{_item.port}}"'Per iteration you get _item, _index, _foreach_node. When done, _results holds the
collected results. An empty collection skips straight to next:.
items_path: reads a list already in context. To iterate a computed/declarative source, use
iterator: instead (see §9). You can also target sub_workflow_id: instead of task:.
7.5 Map
Section titled “7.5 Map”Parallel fan-out — all items at once, throttled by max_concurrency.
- name: build-all map: items_path: images task: build-image max_concurrency: 3 # 0 = unlimited next: { go: summary }
- name: build-image shell: 'echo "[{{_index}}] building {{_item.name}}:{{_item.tag}}"'Same injected variables as ForEach, but _map_node instead of _foreach_node. Actual
parallelism = min(items, max_concurrency, available workers) — so pass enough workers
(-w N) locally to see it. Each branch gets an isolated context clone, so parallel branches
never race. _results is indexed by position (and nests for map-in-map).
7.6 Wait
Section titled “7.6 Wait”Suspend the instance until a signal arrives or a timeout fires. This is how you build human-in-the-loop approvals and event-driven flows.
- name: wait-for-approval wait: timeout: 1h events: - name: approved required: [approver_name] # signal must carry these fields go: deploy # static routing on this event - name: rejected required: [reason] go: rollback error: { go: handle-timeout } # timeout follows the error routerequired:validates the signal payload before the event is accepted.go:is static routing. For dynamic routing, useon:with a handler that callsflow.go("node"):
- name: wait-for-score wait: timeout: 30m events: - name: review_completed required: [score, reviewer] on: | if (signal.score >= 80) flow.go("fast-track"); else if (signal.score >= 50) flow.go("manual-review"); else flow.go("reject");Signal data lands in _signal (full payload) and is also flattened to top-level context.
Send a signal (server mode):
kis signal <instanceID> approved -d approver_name=alicePOST /workflow/signal {"instanceid":"...", "signal_name":"approved", "data":{"approver_name":"alice"}}7.7 SubWorkflow
Section titled “7.7 SubWorkflow”Delegate to a child flow. The parent suspends while the child runs, then resumes with mapped
output. flow: is the preferred key; subworkflow: is an alias.
flows: - name: child-deploy tasks: - name: do-deploy shell: 'echo "deploying {{service_name}} to {{target_env}}"' next: { go: done } - name: done assign: { set: { deploy_status: success } } next: { go: finish } - name: finish succeed: true
- name: parent-pipeline tasks: - name: run-deploy subworkflow: definition_id: child-deploy # or `ref: child-deploy` input_mapping: # parent → child app_name: service_name environment: target_env output_mapping: # child → parent deploy_status: result_status next: { go: summary } - name: summary print: "status={{result_status}}"Empty mappings pass/merge the full context. Preferred keys: flow:/ref:/inputs:/outputs:;
legacy aliases: subworkflow:/definition_id:/input_mapping:/output_mapping:.
7.8 Fail
Section titled “7.8 Fail”Terminate the instance with a typed error. Inline; automatically final.
- name: invalid-config fail: error: ConfigError cause: "Unknown strategy. Use rolling, blue-green, or canary."Short form: fail: "message" → {error: UnspecifiedError, cause: "message"}. Sets _error and
_cause in context.
7.9 Succeed
Section titled “7.9 Succeed”Terminate the instance successfully. Inline; automatically final. If the instance is a nested child, this resumes its parent.
- name: done succeed: true8. Variables, templating & context
Section titled “8. Variables, templating & context”There is one shared bag of data per run: the context (smap.Map). It starts from your
vars: plus -v overrides (which win), and every mechanism reads and writes it.
Templating (Liquid)
Section titled “Templating (Liquid)”String values (shell commands, print messages, iterator configs) are rendered with Liquid:
print: "Deploying {{app}} v{{version}} to {{region}}"print: "processed {{ _results | size }} items" # filters workshell: 'echo "{{_item.host}}"' # dot-paths workRendering is single-pass: when an Assign sets msg: "{{greeting}}", it stores the expanded
value at that point — not a live reference that keeps tracking greeting.
Conditions (the platform scripting runtime)
Section titled “Conditions (the platform scripting runtime)”Routing/decision expressions (choice rules, when:, check:, wait on:) are not Liquid —
they are evaluated by the platform scripting runtime (default JavaScript). Context keys are available as top-level
globals:
choice: rules: - condition: "env === 'production' && approved === true" next: deploy-prodSpecial variables
Section titled “Special variables”| Variable | Set by | Meaning |
|---|---|---|
_item | ForEach, Map | current iteration item |
_index | ForEach, Map | current index (0-based) |
_foreach_node / _map_node | ForEach / Map | the iterating node’s name |
_results | ForEach/Map on completion | collected results (indexed; nested for map-in-map) |
_result | any task completion | the last task’s result |
_error / _cause | Fail | error type / cause |
_signal | Wait (signal) | the full signal payload |
_wait_timeout / _wait_node | Wait (timeout) | timeout flag + node name |
Inside a Map branch,
_itemrefers to that branch’s item. To keep an outer value visible after an inner map shadows_item, stash it with an Assign (region_name: "{{ _item }}") — see the map-in-map recipe in §20.
9. Iterators
Section titled “9. Iterators”ForEach and Map can source items from a declarative iterator instead of a context list. Use
iterator: in place of items_path:. The type: selects the source; each produced row becomes
one _item.
# numbers- name: sweep foreach: iterator: { type: numbers, from: 1, to: 5, step: 1 } task: train-shard
# inline array- name: per-model map: iterator: { type: array, items: ["gpt", "claude", "llama"] } task: bench max_concurrency: 2 # scalar items are boxed as {value} → reference {{ _item.value }}
# files on disk- name: ingest-docs map: iterator: { type: files, path: "{{repo_path}}", glob: "*.yaml", recursive: true } task: ingest max_concurrency: 4Available source types include: numbers, array, time-window, lines, files, dirs,
git-repos, shell-output (and they nest — map-in-map). Iterator config strings are
Liquid-rendered against the context first, so path: "{{repo_path}}" works.
Capability gating. Filesystem/exec/network iterators require capabilities granted to the engine. The local
kis flowCLI grants all; a tenant flow running inside a service leaves them denied unless configured. Preferitems_path:+ a prior task for portable flows.
items_path:vsiterator:— useitems_path:to iterate a list already in context (produced upstream); useiterator:for declarative/computed sources (a number range, files on disk, lines of a file).
10. Selectors & task groups
Section titled “10. Selectors & task groups”Selectors — route a task to the right agent
Section titled “Selectors — route a task to the right agent”- name: gpu-training shell: "train.py" selectors: gpu: true # require a GPU freegpu: 50 # …with ≥50% free VRAM
- name: cpu-job shell: "process.sh" selectors: cpu: 40 # ≥40% free CPU memory: 30 # ≥30% free memory
- name: inference shell: "infer.sh" selectors: tag: inference # only agents tagged "inference"
- name: on-gpt4 shell: "run.sh" selectors: model: gpt-4 # only agents with the gpt-4 model loadedSupported keys: tag, model, gpu, freegpu, cpu, memory, id/workerid, group.
In single-worker (CLI) mode selectors are a no-op — there is only one agent.
You can also pass selectors per-task at start time over HTTP:
{ "workflowname": "selector-example", "selectors": { "gpu-training": { "gpu": true, "freegpu": 50 }, "inference": { "tag": "inference" } }}Task groups — co-locate tasks on one agent
Section titled “Task groups — co-locate tasks on one agent”Tasks in the same group: are guaranteed to run on the same agent — useful when they share
local state (build artifacts, a workspace, model weights).
- name: build shell: "make build" # writes /tmp/build group: compile next: { go: test }
- name: test shell: "make test" # reads /tmp/build — same machine group: compileGroups are lighter than affinity: they scope co-location to a set of tasks within one run.
11. Affinity
Section titled “11. Affinity”Affinity pins execution to specific agents across runs. Configure it at the workflow level:
name: my-workflowaffinity: scope: single-agent # none | single-agent (instance) | definition | payload payload_fields: [env, region] # only for payload scope restart: false # false = resume on agent failure; true = restarttasks: - name: deploy shell: "deploy.sh"| Scope | Effect |
|---|---|
none | No pinning. |
single-agent / instance | All tasks of one run go to one agent. |
definition | All runs of this definition share the same agent. |
payload | Pin by the values of payload_fields. |
At start time you can override with params: pin-agent, affinity, affinity-key,
restart-on-failure. On agent failure the binding is cleared and the run resumes (reassign) or
restarts per restart.
12. Retry & timeout
Section titled “12. Retry & timeout”- name: unreliable-task shell: | curl -f https://flaky.example.com retry: max_attempts: 3 backoff_base: 500ms backoff_max: 5s backoff_multiplier: 2.0 # default 2.0 jitter_fraction: 0.1 # default 0.1 retryable_errors: [Timeout, ConnReset] # optional non_retryable_errors: [AuthError] # optional next: { go: success } error: { go: all-retries-failed } # taken after retries are exhaustedDelay = min(backoff_base · multiplier^attempt, backoff_max) + jitter. When attempts are
exhausted, routing follows error:.
Timeout
Section titled “Timeout”- name: slow-task shell: "long-running.sh" timeout: 5m # execute-phase timeoutFine-grained form (NodeTimeouts): total, before_execute, execute, after_execute.
There is currently no instance-level timeout — bound long-running flows per node.
13. Conditions: check and when
Section titled “13. Conditions: check and when”Two different mechanisms — don’t confuse them:
-
check:is a precondition on a task. If it evaluates false, the node is skipped.- name: deploy-prodcheck: "env === 'production'" # bare string → {expression, language: js}shell: "deploy-prod.sh" -
when:lives inside anext:/error:block and chooses which edge to follow (see §6).
Both use the platform scripting runtime (default JS). A bare string is shorthand for { expression, language: js }.
14. Access control
Section titled “14. Access control”access: gates execution by caller identity, evaluated via the platform scripting runtime. It composes at two scopes:
# workflow-level — gates the whole flow at initaccess: rule: "user.role == 'admin' || user.role == 'operator'" language: js ondeny: fail # fail (default, loud) | skip | sentinel | drop
tasks: - name: sensitive-op access: "user.role == 'admin'" # per-task; ANDed with workflow-level shell: "rotate-keys.sh"AccessConfig fields: rule, language, ondeny, replace (a task with replace: true
overrides the workflow rule instead of ANDing). Deny actions: fail, skip, sentinel, and
drop (Map/ForEach only — filters the item out). This is distinct from check: (a data
precondition, not an authorization gate).
15. Tables (data-driven tasks)
Section titled “15. Tables (data-driven tasks)”Define named datasets and run a task once per row, with columns available as {{col}}:
list: truecontinueonerror: truetables: services: { type: csv, file: ./services.csv } # also: excel (sheet:), or inline pipe-delimitedtasks: - name: build table: services check: '"{{build}}" === "TRUE"' shell: | cd {{folder}}/{{projectname}} go build ./...Table types: csv (with separator), excel (with sheet), or an inline pipe-delimited
string. Files resolve through the repo resolver, falling back to the local filesystem.
16. Pipelines (source/sink streaming)
Section titled “16. Pipelines (source/sink streaming)”Set pipeline: true to build a pipe-and-filter data flow. Tasks declare named input:/output:
ports; the planner wires a producer’s output to the matching consumer’s input, and records stream
between workers through the data pipe.
name: etl-pipelinepipeline: truetasks: - name: extract type: httpclient output: [raw]
- name: transform type: jq input: [raw] output: [clean]
- name: load type: db input: [clean]Records are fed in and inspected via the engine’s SendDataPipeRecord / GetActivePipelines
(this is exactly what aiflow.svc’s POST /workflow/request does under the hood for conversational
pipelines). Optional per-task the platform data layer validation (ValidateDatastore / ValidateEntityName) can
validate records against an entity schema.
17. Scripting flows in JS / Lua / Starlark
Section titled “17. Scripting flows in JS / Lua / Starlark”When YAML isn’t expressive enough, author the flow as a script (.js, .lua, .star). Two modes:
Mode A — structured export
Section titled “Mode A — structured export”Return an object with the same shape as YAML. The last expression is consumed:
var workflow_def = { name: "js-export-example", version: "1.0.0", vars: { env: "staging", app_name: "my-service" }, tasks: [ { name: "setup", assign: { set: { region: "us-west-2" } }, next: { go: "route" } }, { name: "route", choice: { rules: [ { condition: "env === 'production'", next: "deploy-prod" } ], default: "deploy-dev" } }, { name: "deploy-prod", print: "PROD deploy", next: { go: "done" } }, { name: "deploy-dev", print: "DEV deploy", next: { go: "done" } }, { name: "done", succeed: true }, ],};workflow_def; // ← returned to the runtimeMode B — imperative (the flow namespace)
Section titled “Mode B — imperative (the flow namespace)”Each flow.* call appends a task:
flow.name("imperative-deploy");flow.vars({ strategy: "rolling", version: "3.0.0" });
flow.pass("init", { deploy_version: "{{version}}" }, { next: "choose" });flow.choice("choose", { rules: [ { condition: "strategy === 'rolling'", next: "rolling" }, { condition: "strategy === 'blue-green'", next: "bluegreen" }, ], default: "unknown",});flow.print("rolling", "ROLLING deploy", { next: "done" });flow.print("bluegreen","BLUE-GREEN deploy", { next: "done" });flow.fail("unknown", "ConfigError", "Unknown strategy");flow.succeed("done");The same in Lua:
flow.name("lua-workflow-example")flow.vars({ greeting = "Hello", target = "World" })flow.pass("setup", { timestamp = "2026-02-12" }, { next = "show" })flow.print("show", "{{greeting}}, {{target}}!", { next = "done" })flow.succeed("done")Full namespace: name, description, version, loglevel, vars, task, shell, print,
script, assign (pass alias), choice, foreach, map, wait, fail, succeed,
subworkflow. A trailing opts object’s next/error accept a string (→ {go: str}) or an object.
18. Running a flow
Section titled “18. Running a flow”Locally (CLI)
Section titled “Locally (CLI)”kis flow -f my-flow.yaml # run a bare/single flowkis flow -f flows.yaml -n deploy # pick one flow from a multi-flow filekis flow -f my-flow.yaml -v env=prod # override a variablekis flow -f my-flow.yaml -w 4 # provide 4 workers (see Map parallelism)kis signal <instanceID> approved -d approver_name=alice # send a signal to a Wait nodeThe CLI binary may be aliased per environment (
kis,kis-dev,kis-morph, …); the flags are the same.
Via AI Flow (HTTP)
Section titled “Via AI Flow (HTTP)”Deploy the flow as a tenant workflow (delivered through product config), then:
# async startcurl -X POST https://<host>/workflow/start \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"workflowname":"my-flow","context":{"env":"prod"}}'
# synchronous start (blocks for responses)curl -X POST https://<host>/workflow/start/sync/response \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"workflowname":"my-flow","context":{"env":"prod"}}'
# resume / signal / statuscurl -X POST https://<host>/workflow/signal -d '{"instanceid":"...","signal_name":"approved","data":{}}'curl "https://<host>/workflow/status?instanceid=..."See the [HTTP API](/api/AI Flow/) for the full surface.
19. Built-in task types
Section titled “19. Built-in task types”Task types come from the registry the service is built with (aiflow.svc uses the AI-Flow registry,
which includes the common + devops task sets). Commonly available types:
Shell & scripting: shell, supershell, script, python, mojo, ssh
Files & data: file, glob, archive, extract, template/liquid, yaml, json, jq
Network & transfer: httpclient/http, scp, rsync, git, s3, azureblob, scraper
Data stores: db
Secrets & crypto: vault, secret, encrypt/decrypt, hash, acme/legodns, credential-provider
Utility: print, setenv, setvars, id, rule, suspend, throw
DevOps (vulcan set): cron, port, coredns, user, deployproduct, infra, kong, process, scaffold, service
The exact set depends on the deployed registry.
GET /workflow/configreports what a tenant has available. If a task type isn’t registered, planning fails with a clear error.
20. Patterns & recipes
Section titled “20. Patterns & recipes”Fan-out then fan-in (Map → collect)
Section titled “Fan-out then fan-in (Map → collect)”- name: build-all map: { items_path: services, task: build-one, max_concurrency: 4 } next: { go: summary }- name: build-one shell: 'echo "building {{_item.name}}"'- name: summary print: "built {{ _results | size }} services" final: trueHuman approval gate
Section titled “Human approval gate”- name: request-approval print: "awaiting approval…" next: { go: gate }- name: gate wait: timeout: 24h events: - name: approved required: [approver] go: deploy - name: rejected required: [reason] go: abort error: { go: escalate } # timeoutNested parallelism (map-in-map)
Section titled “Nested parallelism (map-in-map)”- name: setup assign: { set: { regions: [us, eu, ap], services: [api, web] } } next: { go: each-region }- name: each-region map: { items_path: regions, task: region-branch, max_concurrency: 3 } next: { go: done }- name: region-branch assign: { set: { region_name: "{{ _item }}" } } # stash before inner _item shadows it next: { go: each-service }- name: each-service map: { items_path: services, task: deploy-one, max_concurrency: 2 }- name: deploy-one shell: 'echo "[{{ region_name }} / {{ _item }}] deploying"'- name: done print: "done across {{ _results | size }} regions" final: true_results comes back nested: [[api,web], [api,web], …] per region.
Retry with a fallback path
Section titled “Retry with a fallback path”- name: call-api httpclient: { url: "https://api.example.com" } retry: { max_attempts: 3, backoff_base: 1s, backoff_max: 30s } next: { go: process } error: { go: use-cache }Conditional multi-environment deploy
Section titled “Conditional multi-environment deploy”See §22 for a full worked example combining Assign → Choice → Map → ForEach → Succeed/Fail.
21. Gotchas & best practices
Section titled “21. Gotchas & best practices”- Node type comes from the key.
shell:,choice:,map:etc. are the type — there’s nonodetype:field. A typo in the key silently makes it a plain Task. - Choice routes with
rules[].next, not the node’snext:. Adding anext:to a Choice node does nothing useful. go:is mandatory in everynext:/error:/when:. Missing it is a parse error.- Templating is single-pass Liquid; conditions are the platform scripting runtime JS. Don’t put
{{ }}in acondition:orcheck:— reference the bare variable (env === 'prod'). - Map needs workers. With one worker (default CLI), a Map runs effectively sequentially. Pass
-w N, or run against a cluster. _itemshadows in nested maps. Stash outer values with an Assign before an inner Map.- Prefer
items_path:over filesystem iterators for flows that must run inside a tenant service (iterator capabilities are denied there by default). - Give nodes stable, unique names — they’re referenced by
go:and used for resume/signal. Duplicate names are a parse error. - Bound long tasks with
timeout:— there’s no instance-level timeout. - Set an explicit
id:for flows you deploy and reference by ID; script/inline flows get a random ULID otherwise. - A Task with no routing is terminal. If a middle node “ends” the flow unexpectedly, it’s
probably missing a
next:.
22. Complete reference example
Section titled “22. Complete reference example”A deployment pipeline exercising most node types:
name: deployment-pipelineloglevel: infovars: strategy: blue-green version: 2.1.0
tasks: # 1. Setup — inject config - name: init-config assign: set: services: - { name: api, port: 8080, replicas: 3 } - { name: worker, port: 9090, replicas: 2 } - { name: gateway, port: 443, replicas: 2 } targets: - { host: prod-01.example.com, zone: us-east } - { host: prod-02.example.com, zone: us-west } deploy_version: "{{version}}" next: { go: choose-strategy }
# 2. Decide — route by strategy - name: choose-strategy choice: rules: - condition: "strategy === 'rolling'" next: build-services - condition: "strategy === 'blue-green'" next: build-services - condition: "strategy === 'canary'" next: setup-canary default: invalid-strategy
- name: invalid-strategy fail: { error: ConfigError, cause: "Unknown strategy. Use rolling, blue-green, or canary." }
- name: setup-canary print: "Setting up canary (10% traffic split)…" next: { go: build-services }
# 3. Build all services in parallel - name: build-services map: { items_path: services, task: build-one-service, max_concurrency: 4 } next: { go: deploy-to-targets }
- name: build-one-service shell: | echo "Building {{_item.name}}:{{deploy_version}} ({{_item.replicas}} replicas)…"
# 4. Deploy to each target sequentially - name: deploy-to-targets foreach: { items_path: targets, task: deploy-to-server } next: { go: verify }
- name: deploy-to-server selectors: { tag: deployer } retry: { max_attempts: 2, backoff_base: 2s, backoff_max: 20s } shell: | echo "Deploying to {{_item.host}} (zone {{_item.zone}}), strategy {{strategy}}" error: { go: deploy-failed }
- name: deploy-failed fail: { error: DeployError, cause: "A target failed to deploy." }
# 5. Verify and finish - name: verify print: "Running post-deployment health checks…" next: { go: deployment-complete }
- name: deployment-complete succeed: trueSee also
Section titled “See also”- [Concepts](/blocks/ai/AI Flow/concepts/) — the mental model behind flows, instances, agents.
- [HTTP API](/api/AI Flow/) — starting/resuming/signaling flows over HTTP.
- [Orchestration](/blocks/ai/AI Flow/orchestration/) — how agents are selected and how selectors resolve.
- the flow engine reference docs — the code-accurate reference the parser follows.
- the flow engine ships runnable example flows — 27 of them, covering v2 nodes and iterators.