Skip to main content

Giving a kind a step body

A kind that wraps a region of work — a transaction, a durable run, a batch — can carry the work inside itself rather than taking a reference to something else that holds it. Point a step array at the shared grammar:

kind: Telo.Definition
metadata:
name: Transaction
capability: Telo.Invocable
# Selects the step canvas in the visual editor. Without it the kind renders as a
# plain form — the body is still valid, but nobody can see its shape.
topology: Sequence
schema:
type: object
required: [steps]
properties:
steps:
title: Steps
description: Work executed inside the transaction.
x-telo-topology-role: steps
type: array
items:
$ref: "telo://manifest#/$defs/Step"

That is the whole declaration. The grammar — invoke / value / if / while / switch / try / throw, the steps.<name>.result accumulator, the error variable inside a catch: — comes with the reference, and so does every analysis built on it: typed step results, throws coverage, the call graph, the editor's step rendering.

What you get, and what stays yours​

The array slot is yours: its title and description, its x-telo-topology-role: steps marker (which field on the canvas holds the body), and any sibling annotation that types the CEL scope your kind offers a step (x-telo-context). So a kind that binds item and index declares those itself, exactly as Run.Iteration does. topology: Sequence on the definition doc is what selects the step canvas at all; the role marker only tells it where to look.

The step is not yours. A slot pointing at the fragment cannot narrow it: draft-07 makes $ref exclusive, so a sibling restating a subset of the branches reaches completion and hover but never the validator. One grammar for every body is the point — a step body means the same thing in a sequence, a transaction and a workflow.

A predicate is a boolean. A step's when, if, elseif[].if and while must evaluate to true or false: telo check reports an expression typed as anything else (CEL_TYPE_ERROR at the predicate), and the engine refuses a result that is not a boolean with ERR_PREDICATE_NOT_BOOLEAN, naming where the predicate is written and what it produced. Nothing is read by truthiness — a string "false" is not false — so compare explicitly (!cel "variables.flag == 'true'").

Running it​

Execution lives in @telorun/sdk beside the grammar, so a controller runs a body without depending on modules/run:

import { StepEngine, type Step } from "@telorun/sdk";

const engine = new StepEngine(ctx, {
kind: "Transaction",
resourceName: String(resource.metadata.name),
});
engine.resolveInvokes(manifest.steps); // once, at init
// …
const steps: Record<string, unknown> = {};
await engine.executeSteps(manifest.steps, steps, undefined, { inputs }, invokeCtx);

A loaded manifest reaches your controller with every inline step target already extracted and named, so resolveInvokes leaves each { kind, name } alone. Keep the call in init() all the same: it is what names the inline targets of a body assembled at runtime (a template body's children, steps built in code). The name is inlineStepTargetName(owner, stepPath, stepName) from @telorun/sdk, the one rule the load pass and the engine share. owner.kind must be exactly your kind's metadata.name and owner.resourceName the resource's metadata.name — the load pass derives both from the manifest, so any other string gives a loaded body's targets different names. The name is durable identity: it is what a trace span and an ERR_RESOURCE_NOT_FOUND print, and what a durable journal records a step's target by. executeSteps takes the accumulator it fills (steps.<name>.result), an optional ScopeContext, the extra CEL variables your kind binds, and the InvokeContext you were invoked with — forwarding the last one is what makes a retry backoff inside the body interruptible. telo check puts inputs in scope only for an Invocable or Runnable composer.

One slot, not two​

A kind with a native steps: has not given up the arbitrary-executable case: a one-step body is exactly that.

steps:
- name: work
invoke: !ref someSequence

So a kind declares steps: or an executable !ref, never both — two spellings of one thing means every annotation that qualifies the body (a zone provision, a retry budget) has to be written twice and kept agreeing.

Publishing one​

The grammar is a shared fragment, which is manifest syntax an older runtime does not know. A module that declares a step body this way must say so:

requires:
telo: ">=0.79.0"

That is what stops telo upgrade from moving a consumer onto a version of your module their runtime cannot read, and what telo check reports when someone runs it against your module directly. Note the gap: a range on an imported library is not yet enforced at load time — the flattened analysis drops the library doc — so a consumer who hand-pins an unreadable version still meets the unresolvable $ref rather than the version message. See Declaring runtime requirements.