Author a workflow
A workflow is a JSON document listing tasks to run in order. This guide is how to write one. It covers how request data reaches your logic, how to branch, how tasks reach outside the process, and how errors come back out.
Before you start
You need a workflow file to edit and orion-server on your PATH for the offline checks. If the words task, data context and condition are new, read Workflows first; this page assumes them.
Parse request payloads first
The raw request payload is not in the expression context. A workflow that reads JSON request data begins with parse_json, which lifts the payload into the data context under a name you choose:
{ "id": "parse", "name": "Parse payload",
"function": { "name": "parse_json", "input": { "source": "payload", "target": "order" } } }
Everything downstream then reads data.order.*. A workflow that does not read a request payload does not need this step.
Warning
Skip this and nothing errors.
{"var": "payload.total"}resolves to nothing, conditions referencingdata.*evaluate against an empty object, every conditional task silently skips, and the response comes back empty. It is the most common way a first workflow “does nothing”.
Shape the data with map
map writes values into the context. Each mapping has a path to write and a logic producing the value: a literal, or a JSONLogic expression over what is already there:
{ "id": "flag", "name": "Flag order",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.order.flagged", "logic": true },
{ "path": "data.order.alert",
"logic": { "cat": ["High-value order: $", { "var": "data.order.total" }] } }
]}}}
Paths are created if they do not exist. Order matters: a mapping can read what an earlier task wrote.
Warning
A misspelled operator is not an error.
{"catt": [...]}is not a mis-typedcat; it is a literal object, and it lands at the target path verbatim. Inside a condition, that literal is truthy, so the condition always fires. Check names against Expression language and dry-run before you activate.
Branch with conditions
Conditions appear at two levels, and picking the wrong one is a common mistake:
| Level | Decides | Use it for |
|---|---|---|
Workflow condition | Whether this workflow matches the request at all | Choosing between two pipelines for one channel |
Task condition | Whether that task runs inside a matched workflow | Branching inside one pipeline |
Most workflows want "condition": true at the top and conditions on individual tasks:
{
"condition": true,
"tasks": [
{ "id": "parse", "function": { "name": "parse_json", "input": { "source": "payload", "target": "order" } } },
{ "id": "vip", "condition": { ">=": [{ "var": "data.order.amount" }, 500] },
"function": { "name": "map", "input": { "mappings": [{ "path": "data.order.tier", "logic": "vip" }] } } },
{ "id": "standard", "condition": { "<": [{ "var": "data.order.amount" }, 500] },
"function": { "name": "map", "input": { "mappings": [{ "path": "data.order.tier", "logic": "standard" }] } } }
]
}
Two tasks with mutually exclusive conditions is how you write an if/else. Write the branches so exactly one fires. Overlapping conditions both run, and the later one wins on any path they share.
Group tasks, and stop early
A tasks element carrying its own tasks key is a task group. It puts one condition on a contiguous run of tasks, instead of the same condition repeated on each:
{ "id": "not_found",
"condition": { "==": [{ "var": "data.user" }, null] },
"terminal": true,
"tasks": [
{ "id": "body", "name": "404 body", "function": { "name": "map", "input": { "mappings": [
{ "path": "data.out", "logic": { "error": "User Not Found" } } ] } } },
{ "id": "status", "name": "404 status", "function": { "name": "map", "input": { "mappings": [
{ "path": "data._orion.response", "logic": { "status": 404, "body_path": "data.out" } } ] } } }
] }
The condition is evaluated once, on entry. A false result skips the whole span without evaluating the members’ own conditions. A task inside the block cannot switch off its own siblings by changing what the condition reads.
terminal: true ends the workflow after the step runs; on a group, after the whole span. Together they are the guard clause: if this, answer and stop. Without it every later task has to restate the negation of every earlier exit, and those conditions grow with each branch you add.
terminal is about position, not outcome. A false condition does not halt, and neither does a skipped task. A task that failed under continue_on_error: true does halt, because the author said nothing after this runs. It also works on a plain task, not only a group.
Groups nest up to 8 deep and share one id namespace with tasks. A group id colliding with a task id is refused at create. The field-by-field contract for both shapes is in Workflow definition.
Note
Task groups need dataflow-rs 3.6, which Orion 1.2.0 ships. A definition using one fails to load on an older engine, loudly. A bare
terminal: trueis silently ignored there and every later task runs. Gate on the server version if you deploy definitions to instances you do not control.
Say a thing once
A shared document is any JSON in the definition set carrying constants, errors or fragments. It declares values and task sequences every workflow can reference. A connector target or an error string then lives in one place instead of being copied per workflow:
{ "input": { "$from": "constants.db", "collection": "users" } }
{ "id": "_session", "use": "require-session", "with": { "deny_message": "Please sign in." } }
$from splices the named value’s fields into the object around it, and siblings win. use expands a parameterized task sequence with its ids namespaced by the call site. Both resolve before validation, so lint, dry-run and test all check the expanded form. The full rules are in Shared definitions.
orion-server lint ./definitions resolves the catalogue automatically and reports an unresolved reference as an error. The single-file commands take --definitions <dir>.
To deploy a set that uses either, compile it first. The admin API takes one document and has no set to resolve names against, so it refuses a reference rather than guessing:
orion-server compile ./definitions --name payments --version 1.4.0 -o dist/package.json
orion-server package apply -s https://prod.orion.internal -f dist/package.json
See compile for the other output formats.
Reach outside the process
Connector-backed tasks call databases, HTTP APIs, caches and Kafka. They name a connector rather than a URL, and write their result to an output path:
{ "id": "enrich", "name": "Look up the customer",
"function": { "name": "http_call", "input": {
"connector": "crm",
"method": "GET",
"output": "data.customer"
}}}
Because the credentials live on the connector and not in the task, your workflow JSON stays safe to commit. See Connect a database or API.
Reach for data_query and data_write first: one backend-neutral envelope that lowers to SQL, MongoDB or Elasticsearch. Drop to db_read and db_write only when you need SQL the dialect cannot express.
Use the scratch space
The context has three namespaces, and the difference matters:
datais the working document. On a sync channel this is the response body, so anything you leave here, the caller sees.metadatais what the ingress recorded: channel id, HTTP method, headers, route parameters.temp_datais scratch: intermediate values you need while computing but do not want to return.
Put working state in temp_data and keep the response clean:
{ "path": "temp_data.raw_score", "logic": { "var": "data.risk.score" } }
Decide what a failure does
By default a task that errors stops the workflow, and the caller gets an error envelope. To collect errors and keep going instead, set continue_on_error:
{ "workflow_id": "order-processing", "continue_on_error": true, "tasks": [ "..." ] }
Note the envelope this produces: "status": "ok" with a non-empty errors array. A client that only checks the HTTP code reads that as success, so anything relying on continue_on_error must inspect errors.
“Errors” here means a handler error or a 5xx. A task that records a 4xx, which is what a failing validation rule does, is a warning to the engine. It is written to the errors array and the next task runs anyway, whatever continue_on_error says. To stop on that, put "halt_on": "failure" on the task. Without it a check reads correct and gates nothing, which orion-server lint reports as engine.unguarded_validation.
For finer control, filter takes on_reject: "halt" to stop the whole workflow, or on_reject: "skip" to skip only that task.
Verify
Check the workflow offline, before it can touch traffic:
orion-server fmt workflow.json
orion-server lint workflow.json
orion-server clippy workflow.json
orion-server dry-run -w workflow.json -i payload.json
fmt writes it in the house style and lint checks it against the schema. clippy reports what lint accepts but you would want to know, only when certain. dry-run executes it against a sample payload and prints the context each task produced. Test a workflow offline covers all four.
Next steps
- Workflow definition: every field, with defaults.
- Task functions: the input each function takes.
- Expression language: the operators you can use in
logicandcondition. - Common workflow patterns: the shapes these pieces make once combined.
Last verified 14 September 2026