A workflow is a single .js file with exactly one workflow({ … }) call. Step logic lives in
plain standalone functions, referenced positionally by the step builders.
The workflow() object
function validate(ctx) { return { validated: true }; }
function charge(ctx) { return { charged: true }; }
workflow({
name: "OrderWorkflow", // required — becomes the definition name
version: "1", // optional display label
description: "process orders",
initial: { order_id: null, total: 0 }, // starting variables; --input is merged over this
inputSchema: { /* JSON Schema */ }, // optional; validates trigger input at start
triggers: [ apiTrigger("create-order") ], // at least one trigger required
steps: [
step("validate", { start: true, next: "charge" }, validate),
step("charge", { next: "end" }, charge),
],
});
workflow() is called once. Keys it reads: name (required), version, description,
initial, inputSchema, outcomeTemplates, inboxTemplates, result, triggers, steps.
Structural rules (enforced at registration):
- Exactly one
stephasstart: true. - Step names are unique.
- Every
next/ branch / loop / foreach / parallel target is another step’s name, or the literal"end"to terminate.
The ctx object
Every handler receives ctx:
ctx.vars— the mutable variable bag, seeded frominitial+ the start--input. Mutate it in place (ctx.vars.x = 1) and/or return an object; the returned object is overlaid onctx.varsafter the handler runs. Both styles compose.ctx.error— populated only inside anonErrorhandler, as{ message, step_name, exception }. It’snulleverywhere else.
function h(ctx) { return { k: v }; } // step handler; return = variable updates
function h(ctx, data) { /* … */ } // form/signal/webhook/agent; data = inbound payload
The step builder
step(name, opts, fn) is the general processing node. The handler fn is the third positional
argument and is always required — even for a step that only routes.
step("validate", { start: true, next: "charge", label: "Validate order" }, validate);
Common opts: start, next, label, userVisible, narrative, and the outgoing-flow
options branches / loop / foreach / parallel (see Control flow),
plus onError / retryPolicy (see Errors & retries). A step uses at
most one of next / branches / loop / foreach / parallel.
Omitting the handler fails the run with step “<name>” has no handler function
— even pure routers. A no-op function decide(ctx) { return {}; }
is fine.
Control directives (action)
Return one of these from a handler to override the declared flow. The global action object is
injected into every handler:
return action.transition("other_step"); // jump to a named step instead of `next`
return action.end(); // end the run now (COMPLETED)
return action.fail("reason"); // mark FAILED — only on a terminal step (see note)
transition and end are returned from the handler and fully control the flow.
action.fail(“reason”) only marks the run FAILED when it’s returned from
a terminal step (one whose flow resolves to “end”); on a step that
routes onward it’s silently dropped, and the reason string isn’t surfaced yet. To fail from the
middle of a flow, route (via onError or a branch) to a dedicated terminal step that
calls action.fail(). Separately, action.log() exists but is currently a
no-op — it emits and persists nothing.
Registering & versioning
flowctl def create --file order.js --version 2 -q # register; prints the definition id
flowctl def graph <def> -o json # verify nodes/edges before running
version: in the DSL is a display label; the authoritative version is the --version N flag on
def create (default 1). Re-registering the same name with a new --version creates a new
version and makes it active.
Next: Control flow.