Composing behaviour
A handler that returns one value is Run.Value. Everything beyond that —
calling several things in order, branching, looping, handling failure — is
Run.Sequence and its siblings in the run module.
imports:
Run: oci://ghcr.io/telorun/run@0.28.0
A sequence is a Telo.Runnable, so it fits anywhere a runnable does: an
application's targets:, an HTTP route's handler:, another sequence's step.
The shape of a sequence
kind: Run.Sequence
metadata:
name: PlaceOrder
inputType:
kind: Telo.JsonSchema
schema:
type: object
properties:
cartId: { type: string }
required: [cartId]
steps:
- name: LoadCart
invoke: !ref GetCart
inputs:
id: !cel "inputs.cartId"
- name: Charge
invoke: !ref PaymentGateway
inputs:
amount: !cel "steps.LoadCart.result.total"
outputs:
orderId: !cel "steps.Charge.result.id"
Four parts, and only steps: is required:
inputType:— the call contract. Callers supply values; the body reads them as!cel "inputs.<name>". Declared defaults are filled and every call is validated before dispatch.steps:— the ordered body. Every step has aname, and its result is visible to later steps as!cel "steps.<name>.result".outputs:— a CEL map turning step results into what the caller sees. Omit it and the caller gets the raw map of step results.outputType:— the result contract. Declaring it typessteps.<name>.resultfor your callers and has the produced value validated at dispatch.
One consequence worth knowing early: a sequence whose inputType has required
fields cannot be a boot target. targets: starts things with run(), which
passes no arguments, so nothing could supply them — telo check reports
CONTRACT_INPUTS_AT_RUN_SITE. Invoke it from a step (or a route) instead.
Step shapes
Beyond name, a step is exactly one of the following.
Invoke
- name: Charge
invoke: !ref PaymentGateway
inputs:
amount: !cel "steps.LoadCart.result.total"
retry:
attempts: 3
delay: 200ms
invoke: takes a !ref to an Invocable or Runnable, or an inline { kind, … }
declaration. inputs: is a CEL-templatable map. retry: is optional.
Value — compute without dispatching
- name: Total
value: !cel "sum(steps.LoadCart.result.lines.map(l, l.price * double(l.qty)))"
A step with value: instead of invoke: publishes steps.Total.result exactly
as an invoke step does, so nothing downstream can tell the difference — but it
dispatches nothing: no resource, no span, no topology node. Reach for it when an
intermediate is derived from an earlier step and would otherwise cost a whole
Run.Value resource to name. It sees the same scope as any other step,
including the enclosing kind's (item / index inside an iteration,
iteration / previous inside a loop).
For intermediates derived from inputs: rather than from a step, Run.Value
and Run.Choice take a bindings: map — see
naming an intermediate value.
Conditional — if / elseif / then / else
- name: BranchOnTotal
if: !cel "steps.LoadCart.result.total > 100"
then:
- name: ApplyDiscount
invoke: !ref Discount
elseif:
- if: !cel "steps.LoadCart.result.total > 50"
then:
- name: ApplySmallDiscount
invoke: !ref SmallDiscount
else:
- name: NoDiscount
invoke: !ref Noop
Loop — while / do
- name: Poll
while: !cel "steps.Check.result.status == 'pending'"
do:
- name: Check
invoke: !ref CheckStatus
A do-while falls out of sharing a step name between an initializer before the loop and the body inside it — each iteration overwrites the previous result.
Switch — switch / cases / default
- name: RouteByRole
switch: !cel "steps.Auth.result.role"
cases:
admin:
- name: AdminAction
invoke: !ref AdminHandler
viewer:
- name: ViewerAction
invoke: !ref ViewerHandler
default:
- name: Reject
invoke: !ref Deny
Error boundary — try / catch / finally
- name: Attempt
try:
- name: Charge
invoke: !ref PaymentGateway
catch:
- name: Compensate
invoke: !ref ReleaseHold
inputs:
reason: !cel "error.message"
failedStep: !cel "error.step"
finally:
- name: Audit
invoke: !ref WriteAuditLog
inputs:
# `error` is NULL here on the success path — guard it, or the
# analyzer reports CEL_NULLABLE_ACCESS
outcome: !cel "error == null ? 'ok' : error.code"
Inside catch:, error carries code, message, step, and data. Inside
finally: it is the same value or null, which is why it must be guarded.
Throw
- name: Reject
throw:
code: UNAUTHORIZED
message: "token expired"
data:
reason: expired
Raises a structured error the nearest enclosing catch: binds as error. Codes
are SCREAMING_SNAKE_CASE by convention.
Guard — when
when: skips a step when the expression is false, and composes with every shape
above:
- name: NotifyOps
when: !cel "variables.environment == 'production'"
invoke: !ref PagerDuty
Scoped resources — with and targets
Some resources should exist only for the duration of a run — a server to test
against, a connection to hold open across steps. with: declares them inline;
they are created when the sequence starts and torn down when it ends. targets:
names which of them to run() first:
kind: Run.Sequence
metadata:
name: CheckTheApi
with:
- kind: Http.Server
metadata:
name: TempServer
listen:
port: 8099
mounts:
- path: /
mount: !ref Api
targets:
- !ref TempServer
steps:
- name: CallIt
invoke: !ref Probe
A scoped name resolves only inside the sequence, and each run gets its own instances — two concurrent runs never observe each other.
An inline step target (invoke: { kind: … }) is created where the sequence is,
not in its scope, so nothing inside it can name a with: resource — neither a
!ref nor resources.<name> in its CEL (telo check reports
SCOPED_NAME_OUT_OF_REACH). Give that target a name under with: and invoke it
with !ref. An inline entry in targets: is the other way round: it is created
in the scope, since that is where a target runs.
The other run kinds
Run.Sequence is the general case. Reach for a narrower kind when it fits — it
says more about intent and is easier to read:
| Kind | For |
|---|---|
Run.Value | Return a shaped value computed with CEL, naming its intermediate steps with bindings:. The declarative alternative to a one-line script. |
Run.Choice | A first-match decision table: return the value of the first choice whose predicate holds, or a declared default. Also takes bindings:, shared across rows. |
Run.Iteration | Run a step body once per collection element, with configurable concurrency. |
Run.Projection | Map each element of a collection through a body into a result array. |
Run.Loop | Repeat a body while a condition holds or up to maxIterations, seeing the iteration count and previous result. |
Run.Detach | Dispatch something in the background and return immediately — off the caller's response path. |
Full field schemas for each are on the hub.
When to reach for a script instead
JavaScript.Script runs inline code and is occasionally the right answer, but
the cases that used to justify it mostly have declarative forms now: arithmetic
no single expression makes readable is bindings: or a value: step, a string
transform is usually one CEL call (regexReplace, split, parseJson,
base64Encode, sha256, uuidv7, nowIso — see the
CEL reference), and branching is Run.Choice.
Prefer composition first: a step graph stays visible to the analyzer, the
editor, and the topology view, while the inside of a script is opaque to all
three. What genuinely remains for a script is a Node.js API the kernel does not
expose — Buffer and byte-level work, streams, a native library. And if you
find yourself writing a script that calls out to something, that something
probably wants to be a resource.
See also
!refand!cel— what is in scope inside a step.- Durable execution — the same step grammar, recorded so a crash and a restart continue where the work stopped.
- Testing your manifests — the same grammar, driving assertions.
- Diagnostics —
UNCOVERED_THROW_CODE,CEL_NULLABLE_ACCESS, and the rest.