Common workflow patterns
Seven shapes cover most workflows. Each states the problem, the pattern, and the mistake it prevents. Use them as recipes: copy the shape, then dry-run it against your own payload.
Before you start
You need a workflow you are authoring and orion-server for the offline checks. The vocabulary is Author a workflow’s; the patterns assume it.
Parse, then process
Problem. Task conditions referencing data.* never fire, and the response comes back empty.
Pattern. Every workflow that reads request data starts with parse_json:
{
"tasks": [
{ "id": "parse", "function": { "name": "parse_json",
"input": { "source": "payload", "target": "order" } } },
{ "id": "process", "condition": { ">": [{ "var": "data.order.total" }, 100] },
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.order.priority", "logic": "high" } ] } } }
]
}
Why it bites. The raw payload sits outside the expression context, so {"var": "payload.total"} resolves to nothing and every condition over data.* evaluates against {}. Nothing errors. The workflow does nothing, which is the hardest failure to read.
Mutually exclusive branches
Problem. Two tasks that were meant as if/else both run.
Pattern. Write branch conditions that cannot both be true:
[
{ "id": "vip", "condition": { ">=": [{ "var": "data.order.amount" }, 500] }, "function": "..." },
{ "id": "standard", "condition": { "<": [{ "var": "data.order.amount" }, 500] }, "function": "..." }
]
Why it bites. Tasks are independent, not exclusive. >= 500 paired with <= 500 both fire at exactly 500, and whichever runs later wins on every path they share. The bug only appears on the boundary value.
Workflow condition or task condition
Problem. A whole workflow is skipped when only one step should have been, or the reverse.
Pattern.
| Use | When |
|---|---|
Workflow condition | You have two different pipelines for one channel and want to pick between them |
Task condition | You have one pipeline with a branch inside it |
Most workflows are "condition": true at the top with conditions on tasks. Reach for a workflow-level condition when the pipelines differ, such as a v2 payload shape alongside a v1. Give the specific one a higher priority so it is evaluated first.
Enrich from an external system
Problem. Credentials end up in workflow JSON, which is then unreviewable and unpromotable.
Pattern. Reference a connector by name and keep the secret in the connector:
{
"tasks": [
{ "id": "parse", "function": { "name": "parse_json",
"input": { "source": "payload", "target": "event" } } },
{ "id": "notify", "function": { "name": "http_call", "input": {
"connector": "slack-webhook",
"method": "POST",
"body_logic": { "var": "data.event" },
"output": "data.notified"
}}}
]
}
Why it bites. A workflow with an embedded credential exports as "******" and is refused on import, so it cannot be promoted at all. And it was in your git history the whole time. See Connect a database or API.
Compose channels in-process
Problem. One service needs another service’s answer, and an HTTP hop between them costs latency and adds a failure mode.
Pattern. channel_call invokes another channel’s workflow in-process. The caller builds the sub-request, calls, and reads the result:
{
"workflow_id": "order-enrichment",
"name": "Order Enrichment",
"description": "Calls the customer-lookup channel in-process with channel_call, then prices the order from the tier it returns.",
"tags": ["pkg:channel-composition"],
"condition": true,
"tasks": [
{
"id": "parse",
"name": "Parse payload",
"function": { "name": "parse_json", "input": { "source": "payload", "target": "order" } }
},
{
"id": "build_lookup",
"name": "Build the lookup request",
"function": {
"name": "map",
"input": {
"mappings": [
{
"path": "temp_data.lookup_request.customer_id",
"logic": { "var": "data.order.customer_id" }
}
]
}
}
},
{
"id": "lookup",
"name": "Call customer-lookup in-process",
"function": {
"name": "channel_call",
"input": {
"channel": "customer-lookup",
"data": { "var": "temp_data.lookup_request" },
"output": "data.customer"
}
}
},
{
"id": "price",
"name": "Apply the tier discount",
"function": {
"name": "map",
"input": {
"mappings": [
{ "path": "data.order.tier", "logic": { "var": "data.customer.lookup.tier" } },
{
"path": "data.order.discount_pct",
"logic": { "var": "data.customer.lookup.discount_pct" }
}
]
}
}
}
]
}
The called channel is an ordinary service with its own workflow, channel and versions. It also happens to be reachable from inside another workflow:
{
"workflow_id": "customer-lookup",
"name": "Customer Lookup",
"description": "Standalone service: resolves a customer id to a tier. Called in-process by order-enrichment through channel_call, and callable on its own.",
"tags": ["pkg:channel-composition"],
"condition": true,
"tasks": [
{
"id": "parse",
"name": "Parse payload",
"function": { "name": "parse_json", "input": { "source": "payload", "target": "lookup" } }
},
{
"id": "vip",
"name": "Known VIP customer",
"condition": { "in": [{ "var": "data.lookup.customer_id" }, [1, 7, 42]] },
"function": {
"name": "map",
"input": {
"mappings": [
{ "path": "data.lookup.tier", "logic": "vip" },
{ "path": "data.lookup.discount_pct", "logic": 15 }
]
}
}
},
{
"id": "standard",
"name": "Everyone else",
"condition": { "!": { "in": [{ "var": "data.lookup.customer_id" }, [1, 7, 42]] } },
"function": {
"name": "map",
"input": {
"mappings": [
{ "path": "data.lookup.tier", "logic": "standard" },
{ "path": "data.lookup.discount_pct", "logic": 0 }
]
}
}
}
]
}
Run the example:
./examples/deploy.sh channel-composition
curl -s -X POST http://localhost:8080/api/v1/data/order-enrichment \
-H 'Content-Type: application/json' \
--data @examples/packages/channel-composition/request.json
Output:
{
"status": "ok",
"data": {
"order": { "order_id": "ORD-5511", "customer_id": 42, "total": 800,
"tier": "vip", "discount_pct": 15 },
"customer": { "lookup": { "customer_id": 42, "tier": "vip", "discount_pct": 15 } }
},
"errors": []
}
Four things this shows, each a common mistake:
- The payload is built first.
channel_call’sdatais a single expression, and JSONLogic has no object constructor, so the caller assembles the sub-request intemp_datawithmap, then passes it by reference. outputreceives the callee’s whole data context, not its HTTP envelope. The lookup workflow parses intodata.lookup, so the caller readsdata.customer.lookup.tier.- The callee is independently callable.
POST /api/v1/data/customer-lookupworks on its own, which is what makes it a service rather than a subroutine. - The call is a function call. No network hop, no serialization round-trip. Cycles are detected and refused.
Two things to know before composing. The called channel applies its own guards, minus the ones a channel_call cannot have: no auth, no origin check, no dedup. And its timeout_ms applies inside your request’s budget, so size the caller’s timeout above the callee’s.
One call per element of an array
Problem. The payload carries a list, and each element needs a connector call. map reshapes an array inside one expression, but it cannot make an http_call per element.
Pattern. A workflow loop repeats the whole task list once per element, with the counter in temp_data as the index. The break is a filter, not the workflow condition:
{
"workflow_id": "notify-each",
"condition": true,
"loop": { "counter": "i", "max": 500 },
"tasks": [
{ "id": "parse", "name": "Parse",
"function": { "name": "parse_json", "input": { "source": "payload", "target": "req" } } },
{ "id": "more", "name": "Stop when done",
"function": { "name": "filter", "input": {
"condition": { "<": [{ "var": "temp_data.i" }, { "var": "data.req.count" }] },
"on_reject": "halt" } } },
{ "id": "send", "name": "Send one",
"function": { "name": "http_call", "input": {
"connector": "notifier", "method": "POST", "path": "/send",
"body_logic": { "val": ["data", "req", "recipients", { "val": ["temp_data", "i"] }] } } } }
]
}
Why it bites. Three traps, all silent:
- Putting the break in the workflow
conditionis the obvious move and it does nothing.datastarts empty, so the condition is false on sweep 0 and the loop never runs once. Put it in afilter, after the parse. bodyis a static field: an expression written there is sent verbatim, not evaluated. The evaluated field isbody_logic. The same split exists on the other connector functions.varcannot index by a computed value. Its second argument is a default, not an index, so{"var": ["data.req.recipients", {"var": "temp_data.i"}]}quietly returns the whole array on every sweep. Dynamic indexing needsvalwith path-chain segments, as above.
The sweeps are sequential and inside one request. Twenty calls at 50 ms is a second of wall clock against your channel timeout. A thousand is a job for an async channel or a service built for it. max is capped by engine.max_loop_iterations.
Collect errors instead of halting
Problem. One optional enrichment fails and the whole request fails with it.
Pattern. Set continue_on_error and read the errors array:
{ "workflow_id": "order-processing", "continue_on_error": true, "tasks": [ "..." ] }
{
"status": "ok",
"data": { "order": { "...": "..." } },
"errors": [ { "code": "IO_ERROR", "task_id": "enrich", "message": "HTTP request failed..." } ]
}
Why it bites. The envelope says "status": "ok" with a non-empty errors array, so a client checking only the HTTP code reads a partial result as a complete one. If you turn this on, the caller must inspect errors. For per-task control instead, filter with on_reject: "skip" skips only its own task.
Verify
Dry-run the pattern against a payload that exercises its boundary. That is the exact threshold value for a branch, an empty list for a loop, or a missing field for a normalizer:
orion-server dry-run -w workflow.json -i boundary.json | jq '.trace.steps[] | {task_id, status}'
The trace names every task and whether it ran or was skipped, which is the answer to “did exactly one branch fire”.
Next steps
- Author a workflow: the how-to these patterns sit on.
- Worked examples: prompt to service: four of these patterns as deployable packages.
- Expression language: the operators, and the misspelling trap.
- Task functions: every function’s input schema.
Last verified 14 September 2026