Skip to main content
You build a workflow by writing a defineWorkflow() definition in your project code. A workflow is a typed input, a typed output, and a chain of steps that runs in order. This page starts from the smallest working workflow, then serves as the full reference for everything you can configure.

Build a simple workflow

You can build a simple workflow in just a few steps.
1

Define the workflow

A workflow needs a slug, name, description, an input schema, and an output schema. Chain its steps, then call .commit(). Create a file in src/workflows/ that default-exports the committed workflow.
src/workflows/greeting.ts
The slug is the stable identity Luno OS uses for discovery, the POST /workflows/{slug} route, CLI commands, and run history.
2

Deploy it

Ship the project to the platform so the workflow runs in the cloud.
Deploy builds and uploads your project, and the CLI now targets it automatically. See deploy a project for project setup.
3

Run it

Invoke the workflow by its slug with a JSON input against your deployed project.
The input is validated against the input schema and the result against output. For repeatable checks, add a test that runs the workflow and asserts its output.
4

Run it from a trigger

In production, workflows usually run from a trigger (a webhook, schedule, or poll attached to the workflow) or as an agent tool. You can also keep invoking it from the CLI or HTTP API; see run workflows.
That is a complete, working workflow. The rest of this page is the full reference for building one.

Configuration reference

defineWorkflow() accepts these options. The required fields are all you need to start; the rest are optional. defineWorkflow() returns a builder. Add steps with the methods below, then finish with .commit(). The committed workflow is what you default-export. .commit() is a type error when the last step’s output does not match output, so add a final .map() when the shapes differ.
Workflow definitions do not have a credentials option. Credentials are declared on the actions a workflow uses, then resolved per step when those steps run. See credentials.

Steps

Every step receives the previous step’s output as its input (the first step receives the workflow input) and produces the next step’s input. TypeScript checks each link of the chain, so a step that cannot accept what the step before it produces is a compile error. Each completed step’s result is recorded, so a retry can skip work that already succeeded.

Action steps

The most common step is an action. Import the action and pass it to .then(). The same action works as a workflow step or an agent tool. Use .map() when one step’s output doesn’t match the next step’s input.
An action step’s id is its slug. To use the same action twice in one workflow, give the second use its own id with .as("id"); to pin its credential scope, use .scope("user"):

Custom steps

When a step needs logic of its own, write it with createStep(). Declare its input and output schemas and an execute function. Inside execute, you can also call an action directly with await myAction.run(input); its credentials bind to the enclosing step.
Prefer chaining actions as steps over calling them inside a custom step: each chained action is its own recorded step and its own node on the canvas.

Agent steps

When a step needs judgment or open-ended tool use, prompt an agent with agentStep(). The prompt runs as a queued child session that you can inspect; the workflow suspends and resumes when the child completes. prompt is a string or a function of the step’s input.
Without output, the step returns { text, sessionId }. Pass a Zod schema as output to get a validated, structured reply instead. The step id defaults to the agent slug; set id to prompt the same agent twice, and sessionId to continue an existing conversation. In Studio, the step’s Config tab has a This step section that overrides the agent for that step only: extra instructions (appended to the agent’s prompt), model, autonomy, max steps, and tool switches. It is versioned like the agent’s own dashboard overrides and merged on top of them. A step can only be stricter: it cannot turn back on a tool the agent turned off, or set Acts on its own under an agent that Always asks. An agent that only runs inside workflow steps (an extractor, a classifier) is not one people should chat with: set chat: false on its defineAgent and the user view leaves it out of the Space’s chat list. Studio still lists it. Mix deterministic action steps with agent steps freely. A common shape is to run actions to gather data, prompt an agent to make a decision or draft text, then run more actions to act on the result.

LLM steps

For a one-shot model call without defining a full agent, use llmStep(). It returns the model’s text, or a validated, structured result when you pass an outputSchema.
Use llmStep() for simple text generation or classification inside a workflow, and a full agent step when the step needs tools, memory, or multi-turn reasoning. The model string follows the same rules as agents — copy an exact id from the models catalog (see Models). When an outputSchema has multiple result shapes, model it as a discriminated union rather than one flat object full of optional/nullable fields — it is more type-safe and avoids a provider union-parameter limit. See structured output schema design.

Sub-workflow steps

To reuse a whole workflow, import it and wrap it in workflowStep(). Luno OS validates the sub-workflow’s input/output, records its result, and gives it its own trace span. Without input, the previous step’s output is passed through; pass input to build the child’s input from it.
The step id defaults to the child’s slug; set id to run the same child twice. The sub-workflow behavior depends on where you run it:
  • Deployed / queued runtime — the child is a queued child run linked to its parent. The parent suspends with status waiting_children until the child completes, then continues with the child’s output. The child can use its own durable waits, appears separately in run history, and counts toward organization concurrency.
  • Local executeWorkflow (tests) — the child runs inline in the same process (no separate child run, and it cannot suspend).
Inspect a parent and its children in one command:

Control flow

Branches, loops, and parallelism are builder methods, so they appear as real structure on the workflow canvas.

Reshape data with map

.map() reshapes data between steps. It receives the previous output as inputData, plus getInitData() (the workflow input) and getStepResult(stepId) (any earlier step’s output). Its result is recorded like a step (map-<n>, so a retry does not re-run it), but keep it pure. Pass { id } to name it on the canvas and in run history.

Branch

.branch() takes [condition, step] pairs and runs every step whose condition holds. The output is keyed by step id, and each key is optional because its branch may not run.

Loops

.foreach(step, { concurrency? }) runs a step for every element of the previous (array) output and produces an array of results. .dowhile(step, condition) repeats a step while the condition holds on its output; .dountil(step, condition) repeats it until the condition holds.

Errors

A step that throws fails the run (after its retries). To handle a failure yourself instead, catch it inside a custom step:

Parallel steps

.parallel([...]) runs independent steps concurrently on the same input. Each one is still its own recorded step, and the output is an object keyed by step id:
An action’s step id is typed as a plain string, so the keyed output is loosely typed; re-validate each entry with the action’s output schema as above (agentStep, llmStep, hookStep, and workflowStep ids are literal types). Give steps distinct ids when the same step appears more than once (.as("id") on actions, id on agentStep / workflowStep). For a dynamic number of items, use .foreach() with a concurrency limit.

Council

A council asks several agents the same question independently and in parallel, then has one agent combine their answers. Use it when independent perspectives (different prompts, models, or tools) beat a single answer: reviews, risk assessments, estimates. Pass councilStep() to .then():
A council is durable like any agent step: on deployed projects each member runs as its own queued child session and the run resumes when all of them finish. The canvas shows the members in a parallel group followed by the synthesis agent.

Durability and retries

Workflows are durable. As each step completes, its result is recorded with the run. If a step fails and the run is retried, it resumes from the failed step: completed steps keep their results and do not run again. A run interrupted by a crash or deploy resumes from the step that was in flight. This has two practical consequences for how you write steps:
  • Put side effects inside steps. Network calls, writes, and other side effects belong in actions or custom steps, where they are recorded once. .map() and branch conditions are for pure data shaping and decisions.
  • Steps should be idempotent where possible. A step can be retried after a transient failure, so design actions to tolerate being called more than once with the same input.
Steps retry in place according to retries: { attempts, delay } on defineWorkflow() (the default for every step; createStep({ retries }) overrides it per step). On top of that, a failed run is re-enqueued by the queue (with backoff) and resumes from the failed step. The runtime records step_completed, step_retrying, and step_failed events as the run progresses, and surfaces them in workflow runs. There is no built-in saga or compensation engine. When a later step fails and you need to undo earlier work, orchestrate it yourself inside a custom step with try/catch (see Errors) and return a flag rather than rethrowing once you’ve handled it. If you let the error propagate instead, the run fails and the queue retries it — fine for transient failures, but any cleanup you add must itself be idempotent.

Workflow state

Steps pass data to each other through their inputs and outputs. For values many steps need (a counter, accumulated results, configuration), declare run state instead: a Zod state schema and an optional initialState (validated when the workflow is defined; schema defaults apply).
createStep steps read state and call setState (declare stateSchema on the step for typed state); .map() gets typed state and setState, and branch and loop conditions get typed state. setState replaces the whole state, so spread the current value to keep other fields. State is saved with the run: it survives sleeps, hooks, retries, crash restarts and time travel.

Schedule

To run a workflow on a timer, add schedule instead of writing a separate schedule trigger. input is the run input on each tick and is typed from the workflow input schema.
Pass an array with an id per entry for several schedules: schedule: [{ id: "eu", cron: "0 9 * * *", input: { region: "eu" } }, { id: "us", ... }]. Each schedule is a regular cron trigger attached to the workflow, with the id daily-report:schedule (or daily-report:schedule:<id>), so it shows up in luno triggers and the dashboard like any other trigger. Cron expressions and timezones are validated exactly as on defineCronSource.

Durable waits

Sometimes a workflow needs to pause, whether to wait an hour or wait for an external approval, without holding a process open. Two durable primitives suspend the run and resume it later.

Sleep

.sleep() suspends the run for a duration (milliseconds or a string like "30s", "10m", "1h", "2d"), and .sleepUntil() until a Date. Both can also take a function of the previous output. The previous output passes through unchanged.

Hooks

hookStep() creates a resume token and a URL, then suspends the run until something calls back. Use it for human-in-the-loop approvals or waiting on an external system. The resume payload becomes the step’s output.
onSuspend runs once when the run starts waiting and receives the step input, the token, and the resumeUrl. Hand the token or URL to whatever will resume the run, such as an approval email link or a Slack button. The payload posted back (or passed as query params on GET) is validated by the optional schema and becomes the step’s output. Set token for a fixed token instead of a random one. See resume a suspended run for the HTTP call. While suspended, the run shows Waiting for a response in run history. Every hook also opens a task in My tasks, where the assignee can answer it from the web app. Set assignee (an email, a Luno user id, { group: "<permission group>" }, a list of those, or a function of the step input), title (default Review <step id>) and dueIn (shown as the due date; it never resolves the hook). A hook whose schema asks for a boolean approved (like the example above) shows as an approval, answered with Approve / Reject, and its default title is Approve <step id>. Without assignee, the task goes to the responders, then the workflow’s owner, then the project admins.

Hooks answered by people outside the project

New hires, external authors and customers are not project members, so give the hook responders. Each responder gets their own signed link to a hosted form built from the hook’s schema. The link is bound to the project, the hook and that email, expires, and works once (the first answer closes the hook for everyone). The raw resumeUrl no longer resumes a responder hook.
The form shows one field per top-level schema property: text inputs for strings (a textarea when .max() is above 200), number inputs, checkboxes, selects for enums, and a JSON textarea for anything nested. Property .describe() text becomes the help line; .meta({ title }) on the object becomes the page title. The page follows the visitor’s language (English or Spanish). The answer is validated against the schema, and the responder’s email is recorded on the hook (responded_by). A hook with expiresIn (or responders) returns { expired: true } when nobody answered by then, and the run continues. Its step output is typed T | { expired: true }; narrow it with isHookExpired. After expiry the resume URL and the links answer 410.
Sleeps and hooks require queued execution. Trigger, CLI, API, sub-workflow, and agent-tool runs all use the queue, so they can suspend and resume normally.

Run context

Inside a step, getWorkflowRunInfo() (from @meetluno/luno/workflow) returns metadata about the current run:

Subscriptions

By default a workflow runs once per triggering event. Setting subscription: { mode: "subscribable" } makes a trigger fan out instead: when the source fires, Luno OS starts one run per enabled subscription. For personal accounts on those runs, pin .scope("user") and assign each subscriber’s user credential to the relevant steps.
Reach for this when one trigger should do per-person work, such as a personal daily digest or a per-user sync, rather than a single shared run. A subscribable workflow with no active subscriptions starts no runs.

Per-subscriber schedules

Add perSubscriber: true to the schedule of a subscribable workflow to fire each subscriber at the cron’s time in their own timezone, instead of everyone at once:
  • Timezone: the subscription’s timezone, else the subscriber’s profile timezone, else the schedule’s timezone, else UTC. Local wall-clock time is kept across DST changes.
  • Working hours and holidays: subscribers set them on their subscription (PUT /workflows/{slug}/subscriptions/{id} with timezone, workingHours: { days: [1, 2, 3, 4, 5], start: "09:00", end: "17:00" } where 0 is Sunday and end is exclusive, and holidays: ["2026-12-25"]). With respectWorkingHours / skipHolidays, a run that falls outside is skipped to the next cron time inside, never run late. A cron that never lands inside (say 0 7 * * * with 09:00 start) does not run for that subscriber.
  • Only one schedule entry per workflow can be perSubscriber, its cron must be minute-level (no seconds field), and updating a subscription recomputes its next run from now.
  • Run now from the dashboard or luno triggers still runs every subscriber.

Next steps

Best practices

Keep workflow steps small, typed, and safe to retry.

Run workflows

Start runs from the CLI, triggers, the API, and agent tools.

Test workflows

Run workflows in tests and assert their output.

Workflow runs

Inspect input, output, steps, errors, and traces.