> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflow builder

> createWorkflow, createStep, the chain methods, and the reference, predicate and mapping helpers that build a workflow graph

The workflow builder turns a chain of method calls into a static, serializable [workflow](/concepts/workflows) graph. `createStep()` declares a code step; `createWorkflow()` returns a builder whose chain methods add steps and containers and whose `.commit()` validates the graph and returns a `LuaWorkflow`. Predicates and data bindings are built with the helpers on this page, never with closures: a function where a predicate, template or mapping is expected fails the build. Workflows live anywhere under `src/` and may be listed on [`LuaAgent`](/reference/sdk/luaagent) under `workflows`. Starting and steering runs from code is [`Workflows`](/reference/sdk/workflows); writing a workflow end to end is the [authoring guide](/build/workflows/authoring).

*Verified against lua-cli 3.33.0.*

```ts theme={null}
import { createWorkflow, createStep, template, step, eq, lit } from 'lua-cli';
```

## Quick example

Two code steps, an agent step, and a conditional agent step placed inside the `switch` by its id:

```ts src/workflows/ticket-triage.ts theme={null}
import { z } from 'zod';
import { createStep, createWorkflow, template, step, eq, lit, Channels } from 'lua-cli';

const loadTicket = createStep({
  id: 'loadTicket',
  inputSchema: z.object({ ticketId: z.string() }),
  outputSchema: z.object({ kind: z.enum(['bug', 'question']), text: z.string() }),
  async execute({ inputData }) {
    const kind = inputData.ticketId.startsWith('BUG') ? ('bug' as const) : ('question' as const);
    return { kind, text: `Ticket ${inputData.ticketId}` };
  },
});

const notify = createStep({
  id: 'notify',
  inputSchema: z.object({}).passthrough(),
  outputSchema: z.object({ sent: z.boolean() }),
  sideEffects: 'external',
  onError: 'park',
  async execute({ once, getInitData }) {
    const { ticketId } = getInitData<{ ticketId: string }>();
    await once(`notify:${ticketId}`, () =>
      Channels.email.send({ to: { email: 'ops@example.com' }, subject: `Ticket ${ticketId} triaged`, text: 'See the run.' })
    );
    return { sent: true };
  },
});

export default createWorkflow({ name: 'ticket-triage', inputSchema: z.object({ ticketId: z.string() }) })
  .then(loadTicket)
  .agentStep('summarise', { agentId: '$self', prompt: template('Summarise: ${stepResults.loadTicket.text}') })
  .switch([[eq(step(loadTicket).path('kind'), lit('bug')), 'triage']])
  .agentStep('triage', { agentId: '$self', prompt: template('Triage this bug: ${stepResults.summarise.text}') })
  .then(notify)
  .commit();
```

## Workflow definition

### createWorkflow()

Returns a `LuaWorkflowBuilder` for the given config. Validation of the config happens here; validation of the graph happens in `.commit()`.

```ts theme={null}
createWorkflow(cfg: LuaWorkflowConfig): LuaWorkflowBuilder
```

<ParamField path="name" type="string" required>
  Server identifier, and what `lua workflows` and `Workflows.start()` address. Must match `^[a-z][a-z0-9-_]*$`.
</ParamField>

<ParamField path="description" type="string">
  Shown in `lua workflows list`.
</ParamField>

<ParamField path="inputSchema" type="ZodType" required>
  Validated on every start; a failing input is refused.
</ParamField>

<ParamField path="outputSchema" type="ZodType">
  Validates the run output.
</ParamField>

<ParamField path="stateSchema" type="ZodType">
  Types `ctx.state`. The run-scoped store holds at most 64 KB.
</ParamField>

<ParamField path="budget" type="{ maxCredits?: number; maxSteps?: number; maxDurationSeconds?: number }">
  Per-run defaults. `maxCredits` counts agent steps, not tokens: an inline agent step settles 1 credit, a `tier: 'job'` attempt 4. `maxDurationSeconds` defaults to 604 800 (7 days), or 2 592 000 (30 days) when the graph contains an approval, a signal wait, or a step with `suspendSchema`. A run that exhausts a dimension parks on a budget gate; raise it with `lua workflows raise-budget`.
</ParamField>

<ParamField path="concurrencyPolicy" type="'allow' | 'forbid'">
  `'forbid'` refuses another start while a run of this workflow is in flight (`RUNS_IN_FLIGHT`, with the blocking run id).
</ParamField>

<ParamField path="outputVisibility" type="{ roles: string[]; users?: string[]; ownerBypass?: boolean }">
  Who may read run outputs. `roles` must be non-empty; at most 20 roles and 50 user ids.
</ParamField>

<ParamField path="schedule" type="JobSchedule & { runAs?: 'installer' | 'system' }">
  The same `{ type: 'cron' | 'interval' | 'once', … }` shape as a [`LuaJob`](/reference/sdk/luajob). `runAs` only affects the copy an [agent template](/concepts/agent-templates) installs: `'installer'` (default) runs as the person who installed, `'system'` runs with no person attached.
</ParamField>

<ParamField path="scheduleInput" type="Record<string, unknown>">
  Literal run input for every scheduled fire. Required when `inputSchema` has required keys (`schedule-input-required`, a push blocker) and validated against it (`schedule-input-invalid`).
</ParamField>

<ParamField path="backfillOnEnable" type="{ maxOccurrences?: number }">
  Missed occurrences to replay when a paused schedule is re-enabled; integer 1–200.
</ParamField>

<ParamField path="goal" type="WorkflowGoalEnvelope">
  `{ objective, judge: { agentId, role?, schema }, cadence: JobSchedule[], maxRuns, budget?, maxTotalCredits?, initialState? }`. See [goals and schedules](/build/workflows/goals-and-schedules).
</ParamField>

<ParamField path="workspace" type="WorkspaceSpec">
  A volume for Job-tier steps: `{ kind: 'git', repo, ref?, credentialsRef?, sizeGb?, ttlHours?, verify?, keepArtefacts?, backend? }` or `{ kind: 'empty', sizeGb?, ttlHours?, keepArtefacts?, backend? }`. `repo` and `ref` accept `env.template()`. See [Job tier](/build/workflows/job-tier).
</ParamField>

<ParamField path="connections" type="Array<{ key: string; integrationType: string; required?: boolean; description?: string }>">
  Connection keys the workflow acts through, resolved against the agent that runs it. `key` matches `^[a-z][a-z0-9_-]{0,63}$` and is what `workspace.credentialsRef` and a step's `requiredConnections` name. See [connections](/build/workflows/connections).
</ParamField>

`form` is set by the compiler; never write it.

**Errors** — `invalid-workflow-name`; `invalid-envelope` for an empty `outputVisibility.roles` or a `backfillOnEnable.maxOccurrences` outside 1–200; `cap-exceeded` for more than 20 roles or 50 users; `env-template-secret-key` when `schedule` or `workspace` carries `env.template()` on a key ending in `SECRET`, `TOKEN`, `KEY` or `PASSWORD`.

### defineWorkflow()

Sugar for `createWorkflow` followed by a build callback that must end in `.commit()`.

```ts theme={null}
defineWorkflow(cfg: LuaWorkflowConfig, build: (wf: LuaWorkflowBuilder) => LuaWorkflow): LuaWorkflow
```

**Errors** — `invalid-envelope` when the callback returns anything other than the committed `LuaWorkflow`.

## Code steps

### createStep()

Declares a code step and returns it unchanged, typed by its schemas. The object is placed in a chain with `.then(step)` or as a container arm.

```ts theme={null}
createStep(s: LuaWorkflowStep<TIn, TOut, TResume>): LuaWorkflowStep<TIn, TOut, TResume>
```

```ts src/workflows/steps/sendEmails.ts theme={null}
import { z } from 'zod';
import { createStep } from 'lua-cli';

const draft = z.object({ to: z.string(), body: z.string() });

export const sendEmails = createStep({
  id: 'sendEmails',
  description: 'Send each draft once',
  inputSchema: z.object({ drafts: z.array(draft) }),
  outputSchema: z.object({ sent: z.number() }),
  timeoutSeconds: 120,
  retry: { maxAttempts: 3, backoffSeconds: 10, backoff: 'exponential', maxBackoffSeconds: 60 },
  sideEffects: 'external',
  onError: 'park',
  async execute({ inputData, once, log, occurrenceId, state }) {
    let sent = 0;
    for (const d of inputData.drafts) {
      const ok = await once(`send:${d.to}`, async () => true);
      if (ok) sent++;
    }
    await state.set('lastOccurrence', occurrenceId);
    log(`sent ${sent}`);
    return { sent };
  },
});
```

<ParamField path="id" type="string" required>
  Unique within the workflow; `^[a-z][a-zA-Z0-9_-]{0,63}$`. The characters `[`, `#`, `.` and `:` are reserved.
</ParamField>

<ParamField path="description" type="string">
  Shown in run listings.
</ParamField>

<ParamField path="inputSchema" type="ZodType" required>
  The step input after bindings are applied.
</ParamField>

<ParamField path="outputSchema" type="ZodType" required>
  The engine validates the returned output; a mismatch fails the attempt.
</ParamField>

<ParamField path="execute" type="(ctx: WorkflowStepContext) => Promise<output>" required>
  The step body. After a resume it runs again from the top with `ctx.resumeData` set.
</ParamField>

<ParamField path="timeoutSeconds" type="number" default={300}>
  Integer ≥ 1. Worker tier allows up to 600; `tier: 'job'` allows up to 14 400 and defaults to 3600. An attempt that runs over is failed with `TIMEOUT` and counts toward `retry.maxAttempts`.
</ParamField>

<ParamField path="retry" type="RetryPolicy" default="{ maxAttempts: 1 }">
  `{ maxAttempts: 1–20; backoffSeconds?: number; backoff?: 'fixed' | 'exponential'; maxBackoffSeconds?: number }`. Backoff is an engine-side wait, not a sleep in your code; `'exponential'` doubles `backoffSeconds` per attempt up to `maxBackoffSeconds` (default 3600), which is only accepted with `'exponential'`. A timed-out attempt is retried under this policy like a thrown error.
</ParamField>

<ParamField path="sideEffects" type="'none' | 'external'" default="'none'">
  `'external'` parks the step for a person instead of retrying it when a platform fault interrupts it.
</ParamField>

<ParamField path="onError" type="'fail' | 'continue' | 'park'" default="'fail'">
  What the final failure does to the run: fail it, continue with `null` as this step's output, or park the run on an exception gate for a person to retry, skip, complete or fail the step.
</ParamField>

<ParamField path="requiredConnections" type="string[]">
  Declared connection keys or connection ids the step needs.
</ParamField>

<ParamField path="suspendSchema" type="ZodType">
  Types the payload passed to `ctx.suspend()`.
</ParamField>

<ParamField path="resumeSchema" type="ZodType">
  Types `ctx.resumeData`, the data a person supplies to resume the step.
</ParamField>

<ParamField path="resumeTimeoutHours" type="number" default={168}>
  Deadline for a `ctx.suspend()` park; at most 720.
</ParamField>

<ParamField path="businessHours" type="WorkflowBusinessHours">
  `{ tz, calendar?: 'mon-fri' | { days, start, end, holidays? } }`. Deadlines count business time.
</ParamField>

<ParamField path="onSuspendTimeout" type="'fail' | 'cancel-run'">
  What a missed `resumeTimeoutHours` does.
</ParamField>

<ParamField path="tier" type="'job'">
  Run the step as a Job-tier pod. Implied by `workspace`.
</ParamField>

<ParamField path="workspace" type="{ mount: 'rw' | 'ro'; isolation?: 'shared' | 'worktree' }">
  Mount the workflow's workspace. Requires `tier: 'job'` or no `tier`.
</ParamField>

<ParamField path="jobResources" type="'small' | 'medium' | 'large'">
  Job-tier pod size; default `'small'`.
</ParamField>

<ParamField path="jobTools" type="WorkflowJobToolId[]">
  `'shell' | 'read' | 'write' | 'edit' | 'glob' | 'grep' | 'git' | 'gh' | 'fetch' | 'ripwire'`. Job tier only.
</ParamField>

**Errors** — `invalid-step` when the argument is not an object or has no `execute`; `invalid-step-id` when `id` is outside the grammar. When the step is placed in a chain: `duplicate-step-id` for a second object with the same `id`; `workspace-requires-job-tier`; `cap-exceeded` for `jobTools` without the Job tier or `retry.maxAttempts` over 20; `invalid-envelope` for a missing, non-integer or sub-1 `retry.maxAttempts`; `backoff-invalid`; `timeout-out-of-range`; `timeout-exceeds-tier` over 600 s on the worker tier; `job-timeout-exceeds-cap` over 14 400 s.

### Step context

`execute` receives one object.

| Member                                                                     | Type and meaning                                                                                                                                                                                                                 |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `runId`, `workflowId`, `workflowVersionId`, `stepId`, `attempt`            | Identity of this invocation                                                                                                                                                                                                      |
| `occurrenceId`                                                             | `<lineageId>:<stepId>`; the same across retries, resumes and `retry-step`; the key for de-duplicating side effects                                                                                                               |
| `lineageId`                                                                | The first run's id in a repair chain; equals `runId` for a normal run                                                                                                                                                            |
| `inputData`                                                                | The step input after bindings, typed by `inputSchema`                                                                                                                                                                            |
| `resumeData`, `suspendData`                                                | After a resume: what the person supplied, and what you passed to `suspend()`                                                                                                                                                     |
| `getInitData<T>()`                                                         | The run input                                                                                                                                                                                                                    |
| `getStepResult<T>(stepId)`                                                 | An upstream step's output; throws `WorkflowStepResultError`                                                                                                                                                                      |
| `state.get(k)`, `state.set(k, v)`                                          | Run-scoped store typed by `stateSchema`; 64 KB total; `set` is durable when the step ends                                                                                                                                        |
| `suspend(payload)`                                                         | Parks the step until a person resumes it; never returns                                                                                                                                                                          |
| `bail(result)`                                                             | Ends the step successfully with `result`; never returns                                                                                                                                                                          |
| `bailRun(output)`                                                          | Ends the whole run successfully with `output`; never returns                                                                                                                                                                     |
| `log(message)`                                                             | Emits a `step.progress` event; at most 1000 per attempt, 1 KB each                                                                                                                                                               |
| `signal`                                                                   | An `AbortSignal` to hand to `fetch` and AI calls. The deployed runtime ends the attempt at the time limit without firing it; `lua test workflow` aborts it at the step wall                                                      |
| `env`                                                                      | The agent's environment variables                                                                                                                                                                                                |
| `once(key, fn)`                                                            | Runs `fn` exactly once per `{ occurrenceId, key }`; a replay returns the stored result; an unsettled earlier claim throws `EFFECT_IN_DOUBT`; results over 32 KB fail with `EFFECT_SETTLE_FAILED`                                 |
| `artefacts.put(name, data, opts)`, `artefacts.get(id)`, `artefacts.list()` | The run's artefact store (files, datasets, images, documents)                                                                                                                                                                    |
| `runtime`                                                                  | `{ trigger, principalKind, parentRunId?, agentVersion?, correlationKey?, tags?, replyTo?, traceparent? }`, read-only                                                                                                             |
| `workspace`                                                                | Job tier only: `{ root, mount, branch?, headSha?, isolation?, baseSha?, arm?, backend? }`; `path` is a deprecated alias of `root`                                                                                                |
| `exec(argv, opts?)`, `` $`…` ``                                            | Job tier only: run one of `git`, `gh`, `pnpm`, `npm`, `npx`, `node`, `yarn`, `python3`, `pytest`, `make` in the workspace; argv only, no shell; a non-zero exit is returned as `result.code`, `exec.strict` and `$.strict` throw |

### WorkflowStepResultError

Thrown by `ctx.getStepResult()`. Check `err.code`, not `instanceof`: a step runs in its own realm.

<ResponseField name="code" type="'STEP_RESULT_NOT_ANCESTOR' | 'STEP_RESULT_TOO_LARGE' | 'STEP_RESULT_OFFLOADED'">
  Not an upstream step; the output could not travel with the step (4 MiB budget per step); or the output is stored offloaded (over 256 KB) and could not be hydrated. For the last two, bind the value through the step's input or read it through `artefacts`.
</ResponseField>

<ResponseField name="stepId" type="string">The requested step.</ResponseField>
<ResponseField name="bytes" type="number">The output's serialized size, for the size codes.</ResponseField>
<ResponseField name="reason" type="string">Why hydration failed, for `STEP_RESULT_OFFLOADED`.</ResponseField>

## Chain methods

Every method returns the builder. A `StepRef` is a `createStep` object or a string naming an entry declared elsewhere in the chain; a `ContainerArm` is a `StepRef` or a `[mapConfig, stepRef]` pair whose step runs with the map as its input.

### then()

Appends a code step, or places a declared entry sequentially by id.

```ts theme={null}
then(step: StepRef): this
```

### parallel()

Runs 2 to 16 arms concurrently. The output is `{ [stepId]: output }`. An arm may be a `[mapConfig, stepRef]` pair, or the id of an `approval` or `waitForSignal`, which then parks beside its siblings.

```ts theme={null}
parallel(steps: ContainerArm[], opts?: { merge?: { strategy: 'rebase' | 'merge'; onConflict: 'fail' | 'agent' } }): this
```

`merge` applies to Job-tier worktree arms. **Errors** — `cap-exceeded` outside 2–16 arms; `container-arm-empty` for a pair without a step; `mapping-placement` for a `map` arm.

### switch()

Runs the first arm whose predicate is true; `otherwise` runs when none is. Without `otherwise`, a run with no true arm continues past the switch. An arm target may be a `map` placed by id. When the switch is the last entry, the taken arm's output is the run output.

```ts theme={null}
switch(arms: Array<[LuaPredicate, StepRef]>, otherwise?: StepRef): this
```

**Errors** — `invalid-envelope` with no arms; `closure-predicate` for a function in the predicate slot.

### branch()

Runs every arm whose predicate is true. `exclusive: true` behaves like `switch` without `otherwise`.

```ts theme={null}
branch(arms: Array<[LuaPredicate, StepRef]>, opts?: { exclusive?: boolean }): this
```

### foreach()

Runs the body once per item. The body receives each item as its input; the output is an array. An `approval` or `waitForSignal` body means one approval or wait per item.

```ts theme={null}
foreach(step: ContainerArm, opts?: ForeachOptions): this
```

<ParamField path="items" type="TypedRef<unknown[]> | MapDescriptor | { initData: true; path?: string }">
  Where the items come from: `step(x).path('list')`, `init('rows')`, `fromInit('rows')`, `{ initData: true, path: 'rows' }`, or a single-step `fromStep(x, 'list')`. Omitted, the body iterates the previous entry's output. `value`, `template`, `fromRequest`, `rows`, `fromKnowledge` and a fan-in `fromStep([…])` are refused (`invalid-envelope`); put a `.map({ '': … }, { id })` before the foreach instead.
</ParamField>

<ParamField path="concurrency" type="number" default={4}>1–16.</ParamField>
<ParamField path="maxItems" type="number" default={256}>1–20 000. A longer list fails the run; it is never truncated.</ParamField>
<ParamField path="chunk" type="{ size: number }">Process items in chunks; `size` is an integer from 1 to `maxItems`. A chunked body cannot be an approval or signal wait.</ParamField>
<ParamField path="rateLimit" type="{ perSecond?: number } | { perMinute?: number }">Exactly one of the two; `perSecond` at most 50, `perMinute` at most 3000.</ParamField>

**Errors** — `cap-exceeded`, `chunk-size-invalid`, `rate-limit-invalid`, `invalid-envelope`; `mapping-placement` for a `[map, step]` body.

### dowhile() and dountil()

Repeats the body while (or until) the predicate holds. The body receives the previous iteration's output. A loop body cannot be an approval, a signal wait, or a `[map, step]` pair.

```ts theme={null}
dowhile(step: ContainerArm, predicate: LuaPredicate, opts?: LoopOptions): this
dountil(step: ContainerArm, predicate: LuaPredicate, opts?: LoopOptions): this
```

<ParamField path="maxIterations" type="number" default={100}>Iteration cap.</ParamField>
<ParamField path="intervalSeconds" type="number">Engine-side wait between iterations; integer 1–86 400.</ParamField>

**Errors** — `loop-interval-out-of-range`; `node-type-unsupported-in-container`; `mapping-placement`.

### map()

Reshapes data at the top level of the chain. Each key is a mapping descriptor or a literal; the single key `''` makes the output the descriptor's value itself rather than an object.

```ts theme={null}
map(mapping: LuaMapConfig, opts?: { id?: string }): this
```

The default id is `map_<n>`. Once a workflow has two or more maps every map needs an explicit `id`: `.commit()` warns `map-id-required` and `lua push` refuses. A map can be a `switch` arm or `otherwise` by id; it can never be a `parallel`, `foreach` or loop arm. **Errors** — `closure-binding` for a function in a descriptor slot.

### sleep()

An engine-side wait of `ms` milliseconds; a literal, non-negative number. The default id is `sleep_<n>`.

```ts theme={null}
sleep(ms: number, opts?: { id?: string; businessHours?: WorkflowBusinessHours }): this
```

### sleepUntil()

Builds a wait until an ISO timestamp or a `template()` that resolves to one. The engine does not execute this step in 3.33.0: `lua compile` and `lua push` refuse it with `node-type-unsupported-by-engine`. Use `sleep()`.

```ts theme={null}
sleepUntil(iso: string | TemplateBinding, opts?: { id?: string; businessHours?: WorkflowBusinessHours; round?: 'next-open' | 'next-close' }): this
```

### agentStep()

Declares an agent turn. The step's output carries the reply as `text`; with `outputSchema`, the reply is validated and a mismatch fails the attempt.

```ts theme={null}
agentStep(id: string, opts: AgentStepOptions): this
```

<ParamField path="agentId" type="string | EnvRefBinding" required>A member agent id, `'$self'` for the owning agent, or `env.template('KEY')`.</ParamField>
<ParamField path="prompt" type="string | TemplateBinding | EnvRefBinding" required>A literal, `template('…')`, or `env.template('KEY')`.</ParamField>
<ParamField path="outputSchema" type="ZodType">Validates the reply.</ParamField>
<ParamField path="model" type="string">Model code for this turn, overriding the agent's.</ParamField>
<ParamField path="toolScope" type="{ connectionIds?: string[]; skillIds?: string[]; toolIds?: string[]; jobTools?: WorkflowJobToolId[] }">Tools offered on this turn; `{}` means none. `jobTools` needs `tier: 'job'`.</ParamField>
<ParamField path="systemPrompt" type="string">A static persona override for the turn.</ParamField>
<ParamField path="timeoutSeconds" type="number">Worker tier up to 600 (default 600 for an agent step); Job tier up to 86 400, and over 14 400 only with a `workspace`.</ParamField>
<ParamField path="retry" type="RetryPolicy">As for code steps.</ParamField>
<ParamField path="onError" type="'fail' | 'continue' | 'park'">As for code steps.</ParamField>
<ParamField path="requiredConnections" type="string[]">As for code steps.</ParamField>
<ParamField path="tier" type="'job'">Run the turn in a Job-tier pod. Implied by `workspace`.</ParamField>
<ParamField path="workspace" type="{ mount: 'rw' | 'ro'; isolation?: 'shared' | 'worktree' }">Mount the workflow's workspace.</ParamField>
<ParamField path="jobResources" type="'small' | 'medium' | 'large'">Pod size.</ParamField>
<ParamField path="harness" type="'claude-code' | 'generic'">Coding harness; Job tier only.</ParamField>
<ParamField path="maxTurns" type="number">Coding turns per harness query; integer 1–500. Job tier only.</ParamField>
<ParamField path="maxMessages" type="number">Harness messages per attempt; integer 1–5000, default 400. Job tier only.</ParamField>
<ParamField path="maxInputTokens" type="number">Input tokens per attempt; integer 1 000 000–500 000 000, default 4 000 000. Job tier only.</ParamField>

**Errors** — `invalid-envelope` without `agentId`; `workspace-requires-job-tier`; `harness-requires-job-tier`; `max-turns-requires-job-tier`; `max-turns-invalid`; `cap-exceeded` for `toolScope.jobTools` off the Job tier; `timeout-exceeds-tier`; `job-timeout-exceeds-cap`; `long-job-requires-workspace`; the retry errors; `invalid-step-id`.

### specialistStep()

Declares a turn of the owning agent under an additive role: a reviewer, verifier or planner without a second agent. Ephemeral roles must be enabled for the organization or agent; otherwise `lua push` refuses with `ephemeral-specialists-disabled`.

```ts theme={null}
specialistStep(id: string, opts: SpecialistStepOptions): this
```

<ParamField path="role" type="{ name: string; instructions: string; tools: string[] } | { ref: string }" required>An inline role (`instructions` at most 4000 characters, `tools` at most 64 names from the agent's own tools; `[]` means none) or a reference to a role in the organization's library (`role-ref-unknown` at push when it does not exist).</ParamField>
<ParamField path="prompt" type="string | TemplateBinding | EnvRefBinding" required>As for `agentStep`.</ParamField>
<ParamField path="outputSchema" type="ZodType">As for `agentStep`.</ParamField>
<ParamField path="model" type="string">As for `agentStep`.</ParamField>
<ParamField path="toolScope" type="{ connectionIds?; skillIds?; toolIds? }">As for `agentStep`.</ParamField>
<ParamField path="timeoutSeconds, retry, onError, requiredConnections" type="—">As for code steps; worker tier only.</ParamField>

**Errors** — `invalid-envelope` without a role or with a non-array `tools`; `role-ref-and-inline` when `ref` is combined with inline fields; `ephemeral-role-too-long`; `cap-exceeded` over 64 tools.

### toolStep()

Calls a [`LuaTool`](/reference/sdk/luatool) directly, with an input built from mapping descriptors.

```ts theme={null}
toolStep(id: string, tool: LuaTool, opts?: ToolStepOptions): this
```

<ParamField path="input" type="LuaMapConfig">The tool input; each key a descriptor or literal.</ParamField>
<ParamField path="timeoutSeconds, retry, sideEffects, onError, requiredConnections" type="—">As for code steps; worker tier only.</ParamField>

**Errors** — `invalid-envelope` when `tool` is not a `LuaTool` value; `closure-binding` for a function inside `input`.

### approval()

Declares a human decision. The approver sees the previous step's output as the payload; the approval's own output is what the next step reads.

```ts theme={null}
approval(id: string, opts: ApprovalOptions): this
```

```ts src/workflows/refund.ts theme={null}
import { z } from 'zod';
import { createStep, createWorkflow, template } from 'lua-cli';

const prepareRefund = createStep({
  id: 'prepareRefund',
  inputSchema: z.object({ ticketId: z.string(), amount: z.number() }),
  outputSchema: z.object({ ticketId: z.string(), amount: z.number() }),
  async execute({ inputData }) {
    return inputData;
  },
});

const issueRefund = createStep({
  id: 'issueRefund',
  inputSchema: z.object({ approved: z.boolean(), decision: z.string(), text: z.string() }).passthrough(),
  outputSchema: z.object({ refunded: z.boolean() }),
  async execute({ inputData, getStepResult }) {
    if (!inputData.approved) return { refunded: false };
    const { amount } = getStepResult<{ ticketId: string; amount: number }>('prepareRefund');
    return { refunded: amount > 0 };
  },
});

export default createWorkflow({ name: 'refund', inputSchema: z.object({ ticketId: z.string(), amount: z.number() }) })
  .then(prepareRefund)
  .approval('approveRefund', {
    title: 'Refund request',
    details: template('Refund ${initData.amount} for ticket ${initData.ticketId}'),
    approver: { role: 'support-lead' },
    excludeInitiator: true,
    timeoutHours: 8,
    businessHours: { tz: 'Europe/London', calendar: 'mon-fri' },
    onTimeout: [{ escalateTo: { role: 'finance-controller' }, timeoutHours: 16 }, 'deny'],
    onDeny: 'continue',
    editablePaths: ['amount'],
    editedPayloadSchema: z.object({ ticketId: z.string(), amount: z.number().positive().max(500) }),
  })
  .then(issueRefund)
  .commit();
```

<ParamField path="title" type="string" required>Plain text shown on the approval card.</ParamField>
<ParamField path="details" type="TemplateBinding">`template('…')` rendered on the card. A function is `closure-binding`.</ParamField>
<ParamField path="approver" type="WorkflowApproverSpec" default="'creator'">`'creator'`, `'org-admins'`, `{ users: string[] }`, `{ role }`, `{ group }`, or `{ governance: { policyId } }`. `users`, `role` and `group` also accept a `template()` binding.</ParamField>
<ParamField path="excludeInitiator" type="boolean">The person who started the run can never approve it. Not allowed with `approver: 'creator'`.</ParamField>
<ParamField path="timeoutHours" type="number | TemplateBinding" default={168}>1–720; a binding is resolved when the step parks.</ParamField>
<ParamField path="onTimeout" type="WorkflowSuspendTimeoutChain" default="'deny'">`'deny'`, `'cancel-run'`, `'fail'`, `'continue'`, or an array of at most 3 `{ escalateTo, timeoutHours }` hops ending in one of those.</ParamField>
<ParamField path="onDeny" type="'continue' | 'fail'" default="'continue'">By default a denial is data the next step reads.</ParamField>
<ParamField path="businessHours" type="WorkflowBusinessHours">Deadlines count business time.</ParamField>
<ParamField path="editable" type="boolean">Let the approver change the payload. Inferred `true` from a non-empty `editablePaths`; an explicit `false` beside paths is refused.</ParamField>
<ParamField path="editablePaths" type="string[]">Paths the approver may edit: `drafts`, `drafts[*]`, `drafts[*].body`, `drafts[3].body`, `summary.title`.</ParamField>
<ParamField path="editedPayloadSchema" type="ZodType">Validates the edited payload. Requires `editable`.</ParamField>
<ParamField path="fourEyes" type="{ edit: WorkflowApproverSpec; approve: WorkflowApproverSpec }">Whoever edits cannot be the one who approves. Requires `editable`.</ParamField>
<ParamField path="itemsPath, itemApprover, itemTimeout" type="—">Per-item approvals over an array in the payload. `itemApprover` may be `{ fromItem: '<field>' }`; `itemTimeout` is `{ timeoutHours }`.</ParamField>

**Output** — read by the next step; the payload the approver saw is not echoed back, so read it with `getInitData()` or `getStepResult()`, or project both into one input with a `.map()` before the step. Give that step's `inputSchema` a `.passthrough()`.

<ResponseField name="approved" type="boolean">`false` on a denial and on a timeout under `'deny'`.</ResponseField>
<ResponseField name="decision" type="'approved' | 'denied' | 'timed_out'">The decision word.</ResponseField>
<ResponseField name="text" type="string">The approver's note, else the decision word.</ResponseField>
<ResponseField name="note" type="string">The note verbatim, when one was left (at most 2000 characters).</ResponseField>
<ResponseField name="editedPayload" type="unknown">The payload as edited, when it was edited.</ResponseField>
<ResponseField name="editRevision" type="number">How many times the payload was edited before the decision.</ResponseField>
<ResponseField name="decidedBy" type="{ id?: string; kind?: string; … }">The deciding principal.</ResponseField>
<ResponseField name="timedOut, escalations, evidence, items" type="—">Set when the deadline chain decided, how many hops fired, the evidence artifact ids, and the per-item rows.</ResponseField>

**Errors** — `invalid-envelope` without `title`; `approver-excludes-only-candidate`; `four-eyes-requires-editable`; `editable-path-invalid`; `escalation-chain-too-long`; `escalation-chain-not-terminal`; `closure-binding`.

### waitForSignal()

Declares a wait for a named signal delivered from outside the run with [`Workflows.signal`](/reference/sdk/workflows), the CLI, or the REST API. A signal that arrives before the wait begins is parked and consumed when it does.

```ts theme={null}
waitForSignal(id: string, opts: WaitForSignalOptions): this
```

```ts src/workflows/pr-review.ts theme={null}
import { z } from 'zod';
import { createStep, createWorkflow } from 'lua-cli';

const openPr = createStep({
  id: 'openPr',
  inputSchema: z.object({ repo: z.string() }),
  outputSchema: z.object({ prNumber: z.number() }),
  async execute() {
    return { prNumber: 42 };
  },
});

export default createWorkflow({ name: 'pr-review', inputSchema: z.object({ repo: z.string() }) })
  .then(openPr)
  .waitForSignal('review', {
    signal: 'github.review',
    schema: z.object({ state: z.enum(['approved', 'changes_requested', 'commented']) }),
    timeoutHours: 168,
    acceptedSources: ['webhook'],
    onTimeout: 'continue',
  })
  .commit();
```

<ParamField path="signal" type="string" required>The signal name a sender must use. The builder only requires a non-empty string; the platform accepts a delivery only when the name is 1–64 characters of `[a-zA-Z0-9_.-]`, so a name outside that grammar can never be satisfied.</ParamField>
<ParamField path="schema" type="ZodType">Validates the payload; a mismatch is rejected at delivery.</ParamField>
<ParamField path="timeoutHours" type="number | TemplateBinding">Deadline for the wait. 1–720, checked when the step parks and on each escalation hop, not at build.</ParamField>
<ParamField path="onTimeout" type="'fail' | 'continue'" default="'fail'">`'continue'` completes the step with `{ received: false, timedOut: true }`.</ParamField>
<ParamField path="businessHours" type="WorkflowBusinessHours">Deadlines count business time.</ParamField>
<ParamField path="acceptedSources" type="Array<'webhook' | 'api' | 'user' | 'agent'>" default="['webhook', 'api', 'user']">Which callers may deliver it.</ParamField>

**Output** — `{ payload, source, signalId, receivedAt }` (`receivedAt` in epoch milliseconds), or `{ received: false, timedOut: true }` after a timeout under `'continue'`. **Errors** — `invalid-envelope` without `signal`.

<Info>
  Local runs only. Under `lua test workflow`, `--signal` completes the wait with the raw payload instead of the `{ payload, source, signalId, receivedAt }` envelope. Read `inputData.payload` in the next step and expect it to be `undefined` offline.
</Info>

### workflow()

Declares a child run of another workflow, by `LuaWorkflow` value or by name on the same agent. The child runs with `trigger: 'workflow'`; nesting is at most 3 deep.

```ts theme={null}
workflow(id: string, ref: LuaWorkflow | string, input?: LuaMapConfig, opts?: NestedWorkflowOptions): this
```

```ts src/workflows/ticket-to-pr.ts theme={null}
import { z } from 'zod';
import { createWorkflow, defineWorkflow, template, fromInit } from 'lua-cli';

const reviewRound = defineWorkflow(
  { name: 'review-round', inputSchema: z.object({ prNumber: z.number() }) },
  (wf) => wf.agentStep('review', { agentId: '$self', prompt: template('Review PR ${initData.prNumber}') }).commit()
);

export default createWorkflow({
  name: 'ticket-to-pr',
  inputSchema: z.object({ prNumber: z.number(), repo: z.string() }),
  workspace: { kind: 'git', repo: 'https://github.com/acme/app', ref: 'main', credentialsRef: 'github' },
  connections: [{ key: 'github', integrationType: 'github', required: true }],
})
  .workflow('round', reviewRound, { prNumber: fromInit('prNumber') }, { workspace: 'inherit', retry: { maxAttempts: 2 } })
  .commit();
```

<ParamField path="input" type="LuaMapConfig">The child run's input, built from descriptors.</ParamField>
<ParamField path="workspace" type="'inherit'">Mount the parent's workspace in the child. The parent must declare one and the child must not.</ParamField>
<ParamField path="retry" type="RetryPolicy" default="{ maxAttempts: 1 }">Re-arms the row with a fresh child run when the child ends `failed` or `timed_out` on its own.</ParamField>

**Errors** — `invalid-envelope` without a workflow or name; `workspace-inherit-without-parent-workspace`; `workspace-inherit-conflict`; `closure-binding` for a function inside `input`.

### commit()

Resolves placements, validates the graph, and returns the `LuaWorkflow`. The compiler observes every commit, so a workflow that is never committed is not compiled.

```ts theme={null}
commit(): LuaWorkflow
```

**Errors** — `empty-graph` when nothing was added; `unknown-step-ref`, `duplicate-step-id`, `mapping-placement`, `container-arm-empty`, `node-type-unsupported-in-container` from placement; `invalid-envelope` on a second `commit()`. **Warnings** (read with `getBuildWarnings()`, printed by `lua compile`) — `map-id-required`, `hitl-duration-defaulted`, `schedule-input-required`, `schedule-input-invalid`.

## Placement rule

Every chain call appends one entry where it is called; nothing is moved afterwards. `agentStep`, `specialistStep`, `toolStep`, `map(…, { id })`, `workflow`, `approval` and `waitForSignal` are declarations: a container (`parallel`, `switch`, `branch`, `foreach`, a loop) that names the id as a string claims the declaration and places it inside itself, whether the declaration comes before or after the container in the chain. A declaration no container claims is placed where it is called, and `.then('<id>')` places one sequentially. A `createStep` object declares no id, so a string arm cannot name it; pass the object.

```ts src/workflows/brief.ts theme={null}
import { z } from 'zod';
import { createWorkflow, template, stepOf, gt, lit, fromStep } from 'lua-cli';

const Angle = z.object({ summary: z.string(), confidence: z.number() });

export default createWorkflow({ name: 'brief', inputSchema: z.object({ topic: z.string() }) })
  .parallel(['techAngle', 'marketAngle'])
  .agentStep('techAngle', { agentId: '$self', prompt: template('Technical angle on ${initData.topic}'), outputSchema: Angle })
  .agentStep('marketAngle', { agentId: '$self', prompt: template('Market angle on ${initData.topic}'), outputSchema: Angle })
  .switch([[gt(stepOf<typeof Angle>('techAngle').path('confidence'), lit(0.6)), 'merge']], 'lowConfidence')
  .map({ brief: fromStep('techAngle', 'summary') }, { id: 'merge' })
  .agentStep('lowConfidence', { agentId: '$self', prompt: 'Say that confidence was too low to publish.' })
  .commit();
```

Here the two angle steps run inside the `parallel`, the map runs inside the switch arm, and `lowConfidence` is the switch's `otherwise`; none of them appears a second time at the top level. The same id placed by two containers is `duplicate-step-id`; a string that names nothing is `unknown-step-ref`. An approval or signal wait may be claimed by `parallel`, `switch`, `branch` or `foreach`, never by a loop, a `[map, step]` pair, or a chunked `foreach` (`node-type-unsupported-in-container`).

## References and predicates

References name a value in the run; predicates compare references and literals. Both are plain data, so a function in their place fails the build.

| Helper                                           | Returns                                                                   |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| `step(createStepObject).path('a.b')`             | `TypedRef` typed from the step's `outputSchema`, up to four levels deep   |
| `step('<id>').path('a.b')`                       | `TypedRef<unknown>` for an entry declared by id                           |
| `stepOf<typeof Schema>('<id>').path('a')`        | A typed ref for an `agentStep` or `toolStep` whose output schema you hold |
| `init<T>('path')`                                | A value from the run input                                                |
| `state<T>('key')`                                | A value from the run state                                                |
| `lit(v)`                                         | A literal: string, number, boolean or `null`                              |
| `eq(l, r)`, `ne(l, r)`                           | Equality between a ref and a ref or literal                               |
| `gt(l, r)`, `gte(l, r)`, `lt(l, r)`, `lte(l, r)` | Numeric comparison; plain numbers are accepted                            |
| `inSet(ref, values)`, `notIn(ref, values)`       | Membership in a literal array                                             |
| `exists(ref)`, `notExists(ref)`                  | Whether the path is present                                               |
| `truthy(ref)`, `falsy(ref)`                      | Truthiness                                                                |
| `and(...p)`, `or(...p)`, `not(p)`                | Combinators                                                               |

```ts src/workflows/predicates.ts theme={null}
import { z } from 'zod';
import { createStep, step, stepOf, init, state, lit, eq, lt, and, inSet, exists } from 'lua-cli';

const Review = z.object({ verdict: z.enum(['pass', 'revise']), round: z.number() });

const fetchSources = createStep({
  id: 'fetchSources',
  inputSchema: z.object({}),
  outputSchema: z.object({ urls: z.array(z.string()), count: z.number() }),
  async execute() {
    return { urls: [], count: 0 };
  },
});

const review = stepOf<typeof Review>('reviewFix');

export const needsAnotherRound = and(eq(review.path('verdict'), lit('revise')), lt(review.path('round'), lit(2)));
export const hasSources = exists(step(fetchSources).path('urls'));
export const isBug = inSet(init<string>('kind'), ['bug', 'regression']);
export const firstAttempt = eq(state<number>('attempts'), lit(0));
```

A predicate that reads a step which is not upstream of the container resolves to an unresolved binding at run time and fails the step before dispatch.

## Mapping descriptors

Descriptors bind step inputs, `map()` keys, tool-step inputs and child-run inputs to values in the run. A member that is not a descriptor is passed through as the literal it is.

| Helper                                                                                 | Resolves to                                                                                                                                                           |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fromInit(path)`                                                                       | A value from the run input; `''` for the whole input                                                                                                                  |
| `fromStep(step, path?)`                                                                | A value from an upstream step's output; `path` omitted means the whole output. `fromStep([a, b], path)` is a fan-in: the first of those steps that produced an output |
| `value(v)`                                                                             | A literal                                                                                                                                                             |
| `template('…')`                                                                        | A string rendered at dispatch from the placeholders under Template placeholders                                                                                       |
| `fromRequest(path)`                                                                    | A field of the run's identity (the `requestContext` fields under Template placeholders)                                                                               |
| `rows(step, path, { offset, limit })`                                                  | One page of an array output that was stored as a dataset                                                                                                              |
| `fromKnowledge({ source: 'org-docs' \| 'memory', query?, docIds?, maxChars?, topK? })` | Retrieved text with a provenance header; `{ source: 'connection', connectionId, … }` reads a connection. Not resolved by the offline runner                           |
| `env.template('KEY')`                                                                  | A per-environment value resolved from the target agent when you `lua push workflow`; legal wherever a template is                                                     |

```ts src/workflows/mapping-demo.ts theme={null}
import { z } from 'zod';
import { createStep, createWorkflow, fromInit, fromStep, value, fromRequest, rows, fromKnowledge, template, env } from 'lua-cli';

const fetchRows = createStep({
  id: 'fetchRows',
  inputSchema: z.object({}),
  outputSchema: z.object({ rows: z.array(z.object({ id: z.string() })) }),
  async execute() {
    return { rows: [] };
  },
});

export default createWorkflow({ name: 'mapping-demo', inputSchema: z.object({ region: z.string() }) })
  .then(fetchRows)
  .map(
    {
      region: fromInit('region'),
      all: fromStep(fetchRows),
      firstPage: rows(fetchRows, 'rows', { offset: 0, limit: 100 }),
      startedBy: fromRequest('userId'),
      mode: value('nightly'),
      policy: fromKnowledge({ source: 'org-docs', query: 'refund policy', topK: 3 }),
    },
    { id: 'shape' }
  )
  .agentStep('summarise', {
    agentId: env.template('SUMMARY_AGENT_ID'),
    prompt: template('Summarise ${stepResults.fetchRows.rows} for ${initData.region} (run ${requestContext.runId})'),
  })
  .commit();
```

### Template placeholders

`template('…')` strings are rendered by the engine when the step is dispatched. Write them in single quotes so TypeScript never interpolates them.

| Placeholder                      | Value                                                                                                                                                                                              |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `${initData.<path>}`             | The run input                                                                                                                                                                                      |
| `${stepResults.<stepId>.<path>}` | An upstream step's output                                                                                                                                                                          |
| `${state.<key>}`                 | The run state                                                                                                                                                                                      |
| `${requestContext.<field>}`      | The run's identity: `runId`, `workflowId`, `workflowVersionId`, `orgId`, `agentId`, `userId`, `trigger`, `triggerId`, `eventId`, `threadId`, `originThreadId`, `parentRunId`, `depth`, `startedAt` |

Objects and arrays render as JSON; `null` and a missing leaf render as an empty string. An unknown namespace (`${inputData.*}` is not one), a step that is not upstream, or a path through a non-object fails the step with `binding_unresolved` before it is dispatched. In an agent prompt each rendered value is wrapped in a data fence marked untrusted, so upstream output reaches the model as data rather than instructions.

### Environment templates

`env.template('KEY')` returns an `EnvRefBinding` placeholder and is accepted for `agentId`, prompts, `workspace.repo` and `ref`, schedule fields and mapping values. `lua push workflow` resolves every key from the target environment; a missing key aborts the push. Keys ending in `SECRET`, `TOKEN`, `KEY` or `PASSWORD` are refused with `env-template-secret-key`; read those with `env('KEY')` inside `execute`. `getEnvTemplateKeys()` lists the keys a workflow carries.

## LuaWorkflow

The value `.commit()` returns. Never construct it yourself.

| Method                    | Returns                                                                     |
| ------------------------- | --------------------------------------------------------------------------- |
| `getName()`               | The workflow name                                                           |
| `getConfig()`             | The `LuaWorkflowConfig` as passed, with `form: 'graph'`                     |
| `getSteps()`              | `Record<string, LuaWorkflowStep>` of every `createStep` object in the chain |
| `getBuildWarnings()`      | `LuaWorkflowBuildWarning[]`: `{ code, message, stepId? }`                   |
| `getEnvTemplateKeys()`    | Sorted, de-duplicated `env.template()` keys                                 |
| `getNestedWorkflowRefs()` | `Array<{ id, name, workspace?: 'inherit' }>` of `.workflow()` targets       |

## Build errors

`createWorkflow`, `createStep`, the chain methods and `.commit()` throw `LuaWorkflowBuildError`, a subclass of `Error` with `name: 'LuaWorkflowBuildError'`.

<ResponseField name="code" type="LuaWorkflowBuildCode">One of the codes in the following table.</ResponseField>
<ResponseField name="message" type="string">The message, with the hint appended after an em dash when one exists.</ResponseField>
<ResponseField name="hint" type="string">What to change, when the builder knows.</ResponseField>

```ts theme={null}
import { z } from 'zod';
import { createWorkflow, LuaWorkflowBuildError } from 'lua-cli';

try {
  createWorkflow({ name: 'Bad Name', inputSchema: z.object({}) });
} catch (err) {
  if (err instanceof LuaWorkflowBuildError) {
    console.error(err.code, err.message, err.hint);
  }
}
```

| Code                                                                                                                                                     | Cause                                                                                                                                                                                                           |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid-workflow-name`, `invalid-step-id`, `invalid-step`                                                                                               | A name or id outside its grammar; a step without `execute`                                                                                                                                                      |
| `duplicate-step-id`, `unknown-step-ref`                                                                                                                  | An id declared or placed twice; a string that names nothing in the chain                                                                                                                                        |
| `empty-graph`                                                                                                                                            | `commit()` on a chain with no entries                                                                                                                                                                           |
| `map-id-required`                                                                                                                                        | Two or more maps without explicit ids (a warning at commit, an error at push)                                                                                                                                   |
| `closure-predicate`, `closure-binding`                                                                                                                   | A function where a predicate, template or mapping is expected                                                                                                                                                   |
| `mapping-placement`, `container-arm-empty`                                                                                                               | A map as a `parallel`, `foreach` or loop arm; a `[map, step]` body in a `foreach` or loop; a pair without a step                                                                                                |
| `node-type-unsupported-in-container`                                                                                                                     | An approval or signal wait as a loop body, in a `[map, step]` pair, or in a chunked `foreach`                                                                                                                   |
| `timeout-out-of-range`, `timeout-exceeds-tier`, `job-timeout-exceeds-cap`, `long-job-requires-workspace`                                                 | A non-integer timeout; over 600 s on the worker tier; over the Job-tier cap; a Job-tier agent step over 4 h without a workspace                                                                                 |
| `workspace-requires-job-tier`, `workspace-inherit-without-parent-workspace`, `workspace-inherit-conflict`                                                | A workspace mount with a tier other than `'job'`; `inherit` without a parent workspace; `inherit` on a child that declares its own                                                                              |
| `harness-requires-job-tier`, `max-turns-requires-job-tier`, `max-turns-invalid`                                                                          | Job-tier members on a worker-tier agent step; a value outside its range                                                                                                                                         |
| `cap-exceeded`                                                                                                                                           | More than 16 `parallel` arms, `foreach` concurrency over 16 or `maxItems` over 20 000, `retry.maxAttempts` over 20, more than 64 role tools, `jobTools` off the Job tier, or `outputVisibility` past its limits |
| `chunk-size-invalid`, `rate-limit-invalid`, `loop-interval-out-of-range`, `backoff-invalid`                                                              | Option values outside their ranges                                                                                                                                                                              |
| `ephemeral-role-too-long`, `role-ref-and-inline`                                                                                                         | Role instructions over 4000 characters; `ref` combined with inline fields                                                                                                                                       |
| `approver-excludes-only-candidate`, `four-eyes-requires-editable`, `escalation-chain-not-terminal`, `escalation-chain-too-long`, `editable-path-invalid` | Approval option conflicts                                                                                                                                                                                       |
| `env-template-secret-key`                                                                                                                                | `env.template()` on a secret-shaped key                                                                                                                                                                         |
| `invalid-envelope`                                                                                                                                       | Any other malformed argument; the message names the field                                                                                                                                                       |
| `schedule-input-required`, `schedule-input-invalid`, `hitl-duration-defaulted`                                                                           | Warnings only                                                                                                                                                                                                   |
| `WORKFLOW_UNPLACED_STEP`                                                                                                                                 | A `createStep` object no workflow places; a warning at compile, refused at push                                                                                                                                 |

`workspace-not-declared` and `approval-inside-container` remain in the `LuaWorkflowBuildCode` union but are not thrown by the builder.

`lua compile` and `lua push` run a second validation over the serialized graph and can refuse what the builder accepted: `node-type-unsupported-by-engine` for `sleepUntil`, `template-reference-unresolved` for a `${stepResults.<id>…}` placeholder that names a step which is not upstream, `connection-key-undeclared` and `connection-declaration-invalid` for connection keys, `ephemeral-specialists-disabled` and `role-ref-unknown` for specialist roles, and `map-member-malformed` (a warning) for a map member that carries descriptor keys without being an exact descriptor.

## The workflow-builder subpath

`lua-cli/workflow-builder` exports the same builder, helpers, `LuaWorkflow` and `LuaWorkflowBuildError` without the runtime client behind them. It exists so a compiled workflow artifact is self-contained; your own files import from `'lua-cli'`. Two constants are exported only there:

| Constant                                | Value     |
| --------------------------------------- | --------- |
| `WORKFLOW_DEFAULT_MAX_DURATION_SECONDS` | `604800`  |
| `WORKFLOW_HITL_MAX_DURATION_SECONDS`    | `2592000` |

## Script form

A workflow can also be a deterministic JavaScript module at `src/workflows/<name>.workflow.script.js`, driven by host helpers instead of this builder. Its shape and limits are described in the [authoring guide](/build/workflows/authoring); a workflow's form is fixed by its first version.

## Types

Exported from `'lua-cli'`: `LuaWorkflow`, `LuaWorkflowBuilder`, `LuaWorkflowConfig`, `LuaWorkflowStep`, `WorkflowStepContext`, `WorkflowStepResultError`, `WorkflowArtefactMeta`, `WorkflowRunTrigger`, `WorkflowGoalEnvelope`, `RetryPolicy`, `StepRef`, `ContainerArm`, `AgentStepOptions`, `AgentToolScope`, `SpecialistStepOptions`, `ToolStepOptions`, `NestedWorkflowOptions`, `ApprovalOptions`, `WaitForSignalOptions`, `ForeachOptions`, `LoopOptions`, `TemplateLike`, `StepPathRef`, `DotPath`, `PathValue`, `WorkflowApproverSpec`, `WorkflowSuspendTimeoutChain`, `WorkflowSuspendTimeoutChainMember`, `WorkflowFourEyes`, `WorkflowBusinessHours`, `WorkflowStepWorkspace`, `WorkflowMergePolicy`, `WorkflowSuspendOnTimeout`, `WorkflowOutputVisibility`, `WorkspaceSpec`, `WorkflowWorkspaceBackend`, `WorkflowSpecialistRole`, `WorkflowJobToolId`, `WorkflowJobHarness`, `ReplyChannel`, `LuaWorkflowBuildError`, `LuaWorkflowBuildCode`, `LuaWorkflowBuildWarning`, and the wire shapes `LuaPredicate`, `MapDescriptor`, `LuaMapConfig`, `TemplateBinding`, `EnvRefBinding`, `TypedRef`, `Literal`, `PathOrLiteral`, `JsonSchema`, `KnowledgeBindingSpec`, `ArtefactRef`, `DatasetRef`, `SerializedWorkflowGraph`.

## See also

* [Workflows](/concepts/workflows) — runs, steps and containers, approvals, budgets, tiers
* [Author a workflow](/build/workflows/authoring) — the guide, including the script form
* [`Workflows`](/reference/sdk/workflows) — start, signal, resume and cancel runs from code
* [Test workflows offline](/build/workflows/test-offline) — `lua test workflow` and fixtures
* [`lua workflows`](/reference/cli/workflows) — push, start, watch, approve, signal
