Transforming Data
Reshape payloads with multi-transform, enrich scopes, and CEL.
In this guide you reshape message data three ways: chain ordered edits with
multi-transform, compute in isolation with enrich, and branch and iterate
with if, foreach, and switch. It follows
samples/multi-transform.yaml,
samples/enrich.yaml,
and
samples/builtins-demo.yaml.
Every expression in this guide is CEL: it sees body (the
message payload), vars (the variables), plus eventID and correlationID.
Chain edits with multi-transform
multi-transform applies an ordered list of CEL edits in one block. Each step
either replaces the body (setBody) or sets a variable (setVar + value),
and the edits are additive: a later step reads what an earlier one
produced. A whole chain of set-payload / set-variable blocks collapses into
one.
flows:
- name: order
process:
- type: multi-transform
name: price-order
settings:
transforms:
# 1) compute the subtotal on the body.
- setBody: '{"orderId": body.orderId, "subtotal": body.qty * body.price}'
# 2) stash it in a variable (reads the body step 1 produced).
- setVar: subtotal
value: body.subtotal
# 3) add tax to the body (reads the subtotal variable step 2 set).
- setBody: '{"orderId": body.orderId, "subtotal": vars.subtotal, "total": vars.subtotal * 1.1}'
- type: log
name: emit
settings:
logger: out
message: '"order " + body.orderId + " subtotal=" + string(body.subtotal) + " total=" + string(body.total)'The flow has no source, so you call it directly with octo invoke:
bin/octo invoke --config samples/multi-transform.yaml --flow order \
--data '{"orderId":"A-1","qty":3,"price":10.0}'It logs order A-1 subtotal=30 total=33 — step 3 read the variable step 2 set
from the body step 1 built.
Compute in isolation with enrich
enrich runs a body sub-flow on an isolated clone of the message, then
merges back only what you name: setBody rewrites the original body from the
scope's result, setVars pulls named values out. Everything else the scope did
— scratch variables, intermediate bodies — stays inside.
Here the scope freely replaces its body with a summary and sets a scratch
variable, but only the computed total escapes; setBody is omitted, so the
original order body survives untouched:
flows:
- name: order
process:
- type: enrich
name: derive-total
# setBody omitted: the incoming order body is preserved.
setVars:
total: body.total # pull just the total out of the scope's summary
body:
process:
- type: set-variable
name: scratch
settings:
name: workingNote
value: '"scope-only, never propagated"'
- type: set-payload
name: summary
settings:
# The scope rewrites the body on its clone; it stays isolated.
value: '{"total": body.qty * body.price, "note": "scope-only body"}'
- type: log
name: emit
settings:
logger: out
message: '"order " + body.orderId + " total=" + string(vars.total)'bin/octo invoke --config samples/enrich.yaml --flow order \
--data '{"orderId":"A-1","qty":3,"price":10.0}'It logs order A-1 total=30 and returns the order body untouched, with total
among the printed message's variables — where enrich put it. Use enrich
when a lookup or computation needs elbow room without polluting the message —
multi-transform when you want the edits to land directly.
Branch and iterate
The control-flow blocks compose with any transform.
samples/builtins-demo.yaml
tours them in one flow:

process:
# set-payload: replace the body with an object holding an orders array.
- type: set-payload
name: seed-orders
settings:
value: '{"firedAt": body.firedAt, "orders": [{"id": 1, "amount": 50}, {"id": 2, "amount": 250}]}'
# set-variable: stash a threshold the switch below compares against.
- type: set-variable
name: set-threshold
settings:
name: threshold
value: "100"
# if/else: branch on whether there is anything to process.
- type: if
name: any-orders
condition: "size(body.orders) > 0"
then:
process:
- type: log
settings:
message: '"processing " + string(size(body.orders)) + " orders at " + body.firedAt'
else:
process:
- type: log
settings:
message: '"no orders to process"'
# foreach: iterate the array, binding each element to `order`.
- type: foreach
name: each-order
items: "body.orders"
as: order
body:
process:
- type: switch
name: classify-order
cases:
- when: "vars.order.amount >= vars.threshold"
process:
- type: log
settings:
message: '"HIGH order " + string(vars.order.id) + " amount=" + string(vars.order.amount)'
default:
process:
- type: log
settings:
message: '"low order " + string(vars.order.id) + " amount=" + string(vars.order.amount)'
# delete-variable: clean up the scratch variable.
- type: delete-variable
name: drop-threshold
settings:
name: thresholdiftakes oneconditionand athen/elsesub-flow.foreachevaluatesitemsto a list and runs itsbodyonce per element, binding each to the variable named byas(herevars.order).switchevaluates itswhencases in order and runs the first match, ordefault.
Run it and watch each tick classify the two orders:
bin/octo run --config samples/builtins-demo.yamlCEL patterns worth stealing
# Default an optional field with a ternary + has():
value: 'has(vars.query.currency) ? vars.query.currency : "USD"'
# CEL is strongly typed: convert before concatenating.
message: '"total=" + string(body.total)'
# Guard a branch on collection size:
condition: 'size(body.orders) > 0'Where to go next
- Expressions — how CEL is wired through Octo.
- CEL reference — the language, macros, and Octo's extensions.
- Data blocks reference — set-payload, set-variable, multi-transform, enrich.
- Composing Flows — factor transforms into reusable flows.