> ## 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.

# Authoring Workflows

> The workflow SDK - steps, containers, bindings, predicates, approvals, signals, budgets and starting runs from code

## Overview

A workflow is written with two functions from `lua-cli`:

* `createStep({...})` declares a code step: typed input and output plus an `execute` function.
* `createWorkflow({...})` returns a builder; chain steps and containers on it and finish with `.commit()`.

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

const wf = createWorkflow({ name: 'my-flow', inputSchema: z.object({ id: z.string() }) })
  .then(loadThing)
  .agentStep('summarise', { agentId: '$self', prompt: template('Summarise ${stepResults.loadThing.text}') })
  .switch([[eq(step(loadThing).path('kind'), lit('bug')), 'triage']], 'done')
  .agentStep('triage', { agentId: '$self', prompt: template('Triage ${stepResults.summarise.text}') })
  .then(done)
  .commit();
```

`defineWorkflow(config, (wf) => wf.then(...).commit())` is equivalent sugar. Put workflows anywhere under `src/` (the compiler finds every `createWorkflow(...).commit()` chain and every `defineWorkflow` call); `src/workflows/` is the convention. Optionally list them on your `LuaAgent` under `workflows: [...]`.

<Warning>
  The graph must be static. Predicates and bindings are built with the helpers below - a function where a predicate, a template or a mapping is expected fails the build with `closure-predicate` / `closure-binding`. Anything that must be computed goes inside a step's `execute`.
</Warning>

## `createWorkflow` config

| Field               | Type                                              | Notes                                                                                                                                              |
| ------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`              | `string`                                          | `^[a-z][a-z0-9-_]*$`. The server identifier and what the CLI addresses.                                                                            |
| `description`       | `string`                                          | Shown in `lua workflows list` and the desktop.                                                                                                     |
| `inputSchema`       | zod schema                                        | Validated on every start (a bad input is a 400).                                                                                                   |
| `outputSchema`      | zod schema                                        | Validates the run output.                                                                                                                          |
| `stateSchema`       | zod schema                                        | Types `ctx.state`; the run-scoped store is capped at 64 KB.                                                                                        |
| `budget`            | `{ maxCredits?, maxSteps?, maxDurationSeconds? }` | `maxDurationSeconds` defaults to 604 800 (7 days), or 2 592 000 (30 days) when the graph has an approval, a signal wait or a suspend-capable step. |
| `concurrencyPolicy` | `'allow' \| 'forbid'`                             | `'forbid'` refuses a new start while a run is in flight (409 `RUNS_IN_FLIGHT` with the blocking run id).                                           |
| `schedule`          | job schedule                                      | The same `{ type: 'cron' \| 'interval' \| 'once', ... }` shape as a `LuaJob`.                                                                      |
| `scheduleInput`     | object                                            | Literal run input for every scheduled fire; required when `inputSchema` has required keys and validated against it.                                |
| `backfillOnEnable`  | `{ maxOccurrences? }`                             | 1..200 missed occurrences to replay when a paused schedule is re-enabled.                                                                          |
| `outputVisibility`  | `{ roles, users?, ownerBypass? }`                 | Restricts who can read run outputs (at most 20 roles and 50 users).                                                                                |
| `workspace`         | workspace spec                                    | A git or empty volume for Job-tier steps. See [Job tier](/workflows/job-tier).                                                                     |

## `createStep`

```typescript theme={null}
const sendEmails = createStep({
  id: 'sendEmails',
  inputSchema: z.object({ drafts: z.array(draft) }),
  outputSchema: z.object({ sent: z.number() }),
  timeoutSeconds: 120,
  retry: { maxAttempts: 3, backoffSeconds: 10, backoff: 'exponential' },
  sideEffects: 'external',
  onError: 'park',
  async execute({ inputData, once, log }) {
    let sent = 0;
    for (const d of inputData.drafts) {
      const r = await once(d.to, () => Channels.email.send({ to: { email: d.to }, subject: 'Hello', body: d.body }));
      if (r) sent++;
    }
    log(`sent ${sent}`);
    return { sent };
  },
});
```

| Field                                           | Notes                                                                                                                                                                                                                             |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                                            | `^[a-z][a-zA-Z0-9_-]{0,63}$`, unique within the workflow.                                                                                                                                                                         |
| `inputSchema` / `outputSchema`                  | zod. The output is validated by the engine; a mismatch fails the attempt.                                                                                                                                                         |
| `execute(ctx)`                                  | Returns the output. Re-runs from the top after a resume.                                                                                                                                                                          |
| `timeoutSeconds`                                | Worker tier 1..600 (default 300). Job tier up to 14 400 for a code step (default 3600).                                                                                                                                           |
| `retry`                                         | `{ maxAttempts, backoffSeconds?, backoff?: 'fixed' \| 'exponential', maxBackoffSeconds? }`. Default `{ maxAttempts: 1 }`. Backoff is an engine timer, not a sleep in your code.                                                   |
| `sideEffects`                                   | `'none'` (default) or `'external'`. An external step that is interrupted by a platform fault is parked for a person instead of being retried automatically.                                                                       |
| `onError`                                       | What the final failure does to the run: `'fail'` (default), `'continue'` (the step's output is `null` and the run goes on), `'park'` (the run waits on an exception gate for a person to retry, skip, complete or fail the step). |
| `requiredConnections`                           | Connection ids the step needs; an unmountable one fails the step with `credentials_revoked`.                                                                                                                                      |
| `suspendSchema` / `resumeSchema`                | Types for `ctx.suspend(payload)` and the `resumeData` a person supplies.                                                                                                                                                          |
| `resumeTimeoutHours`                            | Deadline for a `ctx.suspend()` park (default 168, max 720).                                                                                                                                                                       |
| `businessHours`                                 | `{ tz, calendar?: 'mon-fri' \| { days, start, end, holidays? } }` - deadlines count business time.                                                                                                                                |
| `onSuspendTimeout`                              | `'fail'` or `'cancel-run'`.                                                                                                                                                                                                       |
| `tier`, `workspace`, `jobResources`, `jobTools` | Job tier. See [Job tier](/workflows/job-tier).                                                                                                                                                                                    |

### The step context

`execute` receives one object:

| Member                                                          | What it is                                                                                                          |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `runId`, `workflowId`, `workflowVersionId`, `stepId`, `attempt` | Identity of this invocation.                                                                                        |
| `occurrenceId`                                                  | `<lineageId>:<stepId>` - the same across retries, resumes and repair runs. The key for de-duplicating side effects. |
| `lineageId`                                                     | The first run's id in a repair chain (equal to `runId` otherwise).                                                  |
| `inputData`                                                     | The step input after bindings are applied.                                                                          |
| `resumeData`, `suspendData`                                     | Present after a resume: what the person supplied, and what you passed to `suspend()`.                               |
| `getInitData()`                                                 | The run input.                                                                                                      |
| `getStepResult(stepId)`                                         | The output of an upstream step. Throws `STEP_RESULT_NOT_ANCESTOR` for a step that is not upstream.                  |
| `state.get(k)` / `state.set(k, v)`                              | Run-scoped key-value store (64 KB total).                                                                           |
| `suspend(payload)`                                              | Park this step until a person resumes it with data. Never returns.                                                  |
| `bail(result)`                                                  | Finish this step early with `result` as its output.                                                                 |
| `bailRun(output)`                                               | Finish the whole run successfully with `output`.                                                                    |
| `log(message)`                                                  | Emits a `step.progress` event (at most 1000 per attempt, 1 KB each).                                                |
| `signal`                                                        | An `AbortSignal` that fires on cancel and at the step wall.                                                         |
| `env`                                                           | The agent's environment variables.                                                                                  |
| `once(key, fn)`                                                 | Runs `fn` exactly once per `{ occurrenceId, key }`; a re-run returns the stored result.                             |
| `workspace`                                                     | Job tier only: `{ path, mount, branch?, baseSha?, arm? }`.                                                          |
| `artefacts.put / get / list`                                    | The run's artefact store (files, datasets, images, documents).                                                      |
| `runtime`                                                       | `{ trigger, parentRunId?, correlationKey?, tags?, principalKind, replyTo? }`.                                       |

<Note>
  Execution is at-least-once: a step can run again after a crash, a retry, a resume or a repair run. Wrap anything with an external effect in `ctx.once(key, fn)`, or send your own idempotency key (for example `refund:${ticketId}`) when the effect must be unique across independent runs.
</Note>

## The builder chain

| Call                                                                                | What it adds                                                                                                                                                                                                  |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.then(step)`                                                                       | A code step (or a string naming a step declared elsewhere in the chain).                                                                                                                                      |
| `.parallel([a, b, ...], { merge? })`                                                | 2..16 arms run concurrently. Output is `{ [stepId]: output }`. `merge` applies to Job-tier worktree arms.                                                                                                     |
| `.switch([[predicate, step], ...], otherwise?)`                                     | The first true arm runs.                                                                                                                                                                                      |
| `.branch([[predicate, step], ...], { exclusive? })`                                 | Every true arm runs (`exclusive: true` behaves like `switch`).                                                                                                                                                |
| `.foreach(step, { items?, concurrency?, maxItems?, chunk?, rateLimit? })`           | Runs the step once per item. `concurrency` 1..16 (default 4); `maxItems` default 256, hard ceiling 20 000 - exceeding it fails fast, never truncates. The body receives the raw item; the output is an array. |
| `.dowhile(step, predicate, { maxIterations?, intervalSeconds? })` / `.dountil(...)` | A loop. `maxIterations` default 100; a predicate that is still true at the cap fails the run, so make the predicate bound itself. `intervalSeconds` 1..86 400 waits between iterations.                       |
| `.map({ key: descriptor, ... }, { id })`                                            | Reshapes data. Once a workflow has two or more maps every map needs an explicit `id`. The key `''` means "the output is this value".                                                                          |
| `.sleep(ms)` / `.sleepUntil(iso \| template)`                                       | Engine-side waits; accept `businessHours`.                                                                                                                                                                    |
| `.agentStep(id, opts)`                                                              | An agent turn.                                                                                                                                                                                                |
| `.specialistStep(id, opts)`                                                         | A turn of the owning agent under a role.                                                                                                                                                                      |
| `.toolStep(id, tool, opts)`                                                         | A direct tool call.                                                                                                                                                                                           |
| `.approval(id, opts)`                                                               | A human decision. Top-level only.                                                                                                                                                                             |
| `.waitForSignal(id, opts)`                                                          | Waits for an external signal. Top-level only.                                                                                                                                                                 |
| `.workflow(id, refOrName, input?, { workspace?: 'inherit' })`                       | A child run (nesting depth at most 3).                                                                                                                                                                        |
| `.commit()`                                                                         | Validates and returns the `LuaWorkflow`.                                                                                                                                                                      |

### Placement: declare inside the container

A string in a container refers to a step declared by `agentStep` / `specialistStep` / `toolStep` / `map(..., { id })` anywhere else in the same chain, and places that step **inside** the container:

```typescript theme={null}
createWorkflow({ ... })
  .parallel(['techAngle', 'marketAngle'])                 // two arms, declared below
  .agentStep('techAngle', { ... })                          // placed by the parallel above, not a new entry
  .agentStep('marketAngle', { ... })
  .switch([[gt(stepOf<typeof angle>('techAngle').path('confidence'), lit(0.6)), 'merge']], 'lowConfidence')
  .map({ brief: fromStep('techAngle', 'summary') }, { id: 'merge' })   // inside the switch arm
  .agentStep('lowConfidence', { ... })                      // the switch's otherwise
  .commit();
```

If a conditional is the last entry, the taken arm's output is the run output.

## Data flow

### Mapping helpers

| Helper                                                              | Meaning                                                                                        |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `fromInit(path)`                                                    | A value from the run input.                                                                    |
| `fromStep(step, path?)`                                             | A value from an upstream step's output (`path` omitted = the whole output).                    |
| `value(v)`                                                          | A literal.                                                                                     |
| `rows(step, path, { offset, limit })`                               | A page of rows from an array-typed output that was stored as a dataset.                        |
| `fromKnowledge({ source, query, maxChars?, topK?, connectionId? })` | Text retrieved from `org-docs`, `memory` or a `connection`, rendered with a provenance header. |
| `fromRequest(path)`                                                 | A value from the request that started the run.                                                 |
| `env.template(KEY)`                                                 | A per-environment value resolved at push time (see below).                                     |

### Prompt bindings

`template('...')` strings are resolved by the engine when the step is dispatched:

* `${initData.<path>}` - the run input
* `${stepResults.<stepId>.<path>}` - an upstream output
* `${state.<key>}` - the run state

Objects and arrays are JSON-encoded inside the prompt; a missing value is left verbatim. Prompt strings are plain single-quoted strings so TypeScript never interpolates them.

### Predicates

Build predicates from typed references and literals:

```typescript theme={null}
import { step, stepOf, init, state, lit, eq, ne, gt, gte, lt, lte, inSet, notIn, exists, notExists, truthy, falsy, and, or, not } from 'lua-cli';

step(fetchSources).path('urls')                    // typed by the step's outputSchema
stepOf<typeof Review>('reviewFix').path('verdict')  // typed string ref for an agentStep id
init<string>('kind')                                // the run input
state<number>('attempts')                           // run state

and(eq(reviewRef.path('verdict'), lit('revise')), lt(reviewRef.path('round'), lit(2)))
```

## Agent, specialist and tool steps

### `agentStep(id, opts)`

| Option                                                      | Notes                                                                                                                                                                                        |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentId`                                                   | A sub-agent id, `'$self'` for the owning agent, or `env.template(KEY)`.                                                                                                                      |
| `prompt`                                                    | A string, `template('...')` or `env.template(KEY)`.                                                                                                                                          |
| `outputSchema`                                              | zod. The model's reply is validated; a reply that does not match fails the attempt (`output_schema_invalid`), so pair it with `retry`.                                                       |
| `model`                                                     | Override the agent's model for this turn.                                                                                                                                                    |
| `toolScope`                                                 | `{ connectionIds?, skillIds?, toolIds?, jobTools? }`. `{}` means no tools. A step whose prompt carries external content (customer messages, retrieved knowledge) must declare a `toolScope`. |
| `systemPrompt`                                              | A static persona override for the turn.                                                                                                                                                      |
| `timeoutSeconds`, `retry`, `onError`, `requiredConnections` | As for code steps.                                                                                                                                                                           |
| `tier`, `workspace`, `jobResources`, `harness`, `maxTurns`  | Job tier only. See [Job tier](/workflows/job-tier).                                                                                                                                          |

### `specialistStep(id, opts)`

Runs **your own agent** with an additive role - a name, instructions (at most 4000 characters) and a tool allowlist drawn from the agent's own tools (at most 64; delegation tools cannot be allowlisted). Use it for a reviewer, a verifier or a planner without creating another agent:

```typescript theme={null}
.specialistStep('review', {
  role: {
    name: 'Reviewer',
    instructions: 'You are a sceptical reviewer. Check every claim against a source you can cite; flag anything unsupported. Never rewrite the draft - return findings only.',
    tools: ['searchWeb', 'fetchUrl'],
  },
  prompt: template('Review the draft: ${stepResults.draft.text}'),
  outputSchema: z.object({ findings: z.array(z.string()), verdict: z.enum(['pass', 'revise']) }),
})
```

`role: { ref: 'reviewer' }` points at a role from your organisation's role library instead of an inline definition.

### `toolStep(id, tool, opts)`

Calls a `LuaTool` directly with `input` built from mapping helpers, plus `timeoutSeconds`, `retry`, `sideEffects`, `onError` and `requiredConnections`.

## Approvals

```typescript theme={null}
.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 }, { escalateTo: 'org-admins', timeoutHours: 24 }, 'deny'],
  editable: true,
  editablePaths: ['amount'],
  editedPayloadSchema: z.object({ ticketId: z.string(), amount: z.number().positive().max(500) }),
  fourEyes: { edit: { role: 'support-lead' }, approve: { role: 'finance-controller' } },
})
```

| Option                                             | Notes                                                                                                                           |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `title`                                            | Plain string, required.                                                                                                         |
| `details`                                          | `template('...')`, shown on the card.                                                                                           |
| `approver`                                         | `'creator'` (default), `'org-admins'`, `{ users: [...] }`, `{ role }`, `{ group }` or `{ governance: { policyId } }`.           |
| `excludeInitiator`                                 | The person who started the run can never approve it. Not allowed with `approver: 'creator'`.                                    |
| `timeoutHours`                                     | Default 168, 1..720; may be a template binding.                                                                                 |
| `onTimeout`                                        | `'deny'` (default), `'cancel-run'`, `'fail'`, or a chain of up to 3 `{ escalateTo, timeoutHours }` hops ending in one of those. |
| `onDeny`                                           | `'continue'` (default - a denial is data your next step reads) or `'fail'`.                                                     |
| `businessHours`                                    | Deadlines count business time.                                                                                                  |
| `editable`, `editablePaths`, `editedPayloadSchema` | Let the approver change the payload. Paths follow `drafts`, `drafts[*].body`, `drafts[3].body`, `summary.title`.                |
| `fourEyes`                                         | `{ edit, approve }` - whoever edits cannot be the one who approves. Requires `editable: true`.                                  |
| `itemsPath`, `itemApprover`, `itemTimeout`         | Per-item approvals over an array in the payload, each routed to its own approver (`{ fromItem: 'approverEmail' }`).             |

The card shows the previous step's output as the payload. The approval's own output is what the next step reads:

```typescript theme={null}
{ approved: boolean, timedOut?: boolean, note?: string, editedPayload?: T, input: T, escalations?: number }
```

Approvals and signal waits are top-level entries - they cannot sit inside `parallel`, `foreach` or a loop.

## Signals

A `waitForSignal` step parks the run until something outside delivers a named signal:

```typescript theme={null}
.waitForSignal('review', {
  signal: 'github.review',
  schema: z.object({ state: z.enum(['approved', 'changes_requested', 'commented']) }),
  timeoutHours: 168,
  acceptedSources: ['webhook'],
  onTimeout: 'continue',
})
```

| Option            | Notes                                                                                                           |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| `signal`          | 1..64 characters of `[a-zA-Z0-9_.-]`.                                                                           |
| `schema`          | zod; a payload that does not match is rejected with `SIGNAL_SCHEMA_INVALID`.                                    |
| `timeoutHours`    | Deadline; `onTimeout: 'fail'` (default) or `'continue'` (output `{ received: false, timedOut: true }`).         |
| `acceptedSources` | Which callers may deliver it: `'webhook'`, `'api'`, `'user'`, `'agent'` (default `['webhook', 'api', 'user']`). |

The step's output is `{ received: true, payload }` (or the timed-out shape above). Deliver signals from a webhook with `Workflows.signal`:

```typescript theme={null}
import { LuaWebhook, Workflows } from 'lua-cli';

export default new LuaWebhook({
  name: 'github-pr-review',
  async execute({ headers, body }) {
    if (headers['x-github-event'] !== 'pull_request_review') return { ignored: true };
    const runId = /<!-- lua-run:(wfr_[A-Za-z0-9_-]+) -->/.exec(body.pull_request?.body ?? '')?.[1];
    if (!runId) return { ignored: true };
    const r = await Workflows.signal(runId, 'github.review', { state: body.review.state }, { dedupeKey: `review:${body.review.id}` });
    return { runId, accepted: r.accepted, reason: r.reason };
  },
});
```

A signal that arrives before the run waits for it is parked and consumed when the wait begins (each run holds at most 256 parked signals; payloads are at most 64 KB). `dedupeKey` makes a redelivered webhook a no-op. A signal that reaches a parent run is handed down to a waiting child run.

## Nested workflows

```typescript theme={null}
.workflow('reviewRound', prReviewRound, { prNumber: fromStep('openPr', 'prNumber'), repo: fromInit('repo') }, { workspace: 'inherit' })
```

The child is a run of its own (`trigger: 'workflow'`), nested at most 3 deep. Reference another workflow by its `LuaWorkflow` value or by name on the same agent. `workspace: 'inherit'` runs the child on the parent's Job-tier workspace.

## Starting runs from code

`Workflows` is available in tools, jobs, webhooks, triggers and workflow code steps:

```typescript theme={null}
import { Workflows } from 'lua-cli';

const { runId, status, idempotentReplay } = await Workflows.start('outreach', { leads }, {
  idempotencyKey: `outreach:${batchId}`,
  correlationKey: batchId,
  tags: ['weekly'],
  budget: { maxCredits: 40 },
});
```

| Method                                                                | Notes                                                                                                                                                                                                                                                                                           |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start(nameOrId, input?, opts?)`                                      | Fire-and-return; never awaits execution. `opts`: `idempotencyKey`, `budget`, `waitSeconds` (0..55, a server long-poll), `initialState`, `correlationKey`, `tags` (at most 10), `replyTo`, `onBehalfOf`, `workflowVersionId`. Returns `{ runId, status }` where `status` is `queued` or `gated`. |
| `get(runId)`                                                          | The run (a restricted run comes back with `restricted: true` and no `output`).                                                                                                                                                                                                                  |
| `list({ limit?, status?, workflow?, correlationKey?, tags?, sort? })` | Runs on the agent.                                                                                                                                                                                                                                                                              |
| `cancel(runId, { mode?: 'request' \| 'force', reason? })`             | See [cancel](/workflows/runs-and-events#cancelling).                                                                                                                                                                                                                                            |
| `resume(runId, stepId, resumeData)`                                   | Resume a step parked by `ctx.suspend()`.                                                                                                                                                                                                                                                        |
| `signal(runId, name, payload?, { dedupeKey? })`                       | Deliver a signal.                                                                                                                                                                                                                                                                               |
| `signalByKey(nameOrId, correlationKey, name, payload?, opts?)`        | Deliver to the live run(s) with that correlation key.                                                                                                                                                                                                                                           |
| `startBatch(nameOrId, items, opts?)`                                  | Start many runs; each item carries its own `idempotencyKey`.                                                                                                                                                                                                                                    |
| `raiseBudget(runId, patch)`                                           | Raise a parked run's budget.                                                                                                                                                                                                                                                                    |

**Idempotency.** A second `start` with the same `idempotencyKey` on the same agent returns the original run (`idempotentReplay: true`) instead of creating another. Keys are at most 128 characters. A `concurrencyPolicy: 'forbid'` workflow with a run in flight throws `RUNS_IN_FLIGHT` with `blockingRunId`.

From a code step, `Workflows.start` creates a detached run; use the `.workflow()` node when the parent should wait for the child.

### From a trigger

A `LuaTrigger` can start a run by returning `startWorkflow` from `transform`:

```typescript theme={null}
export default new LuaTrigger({
  name: 'linear-ready-for-agent',
  source: 'webhook',
  filter: (ctx) => ctx.payload?.type === 'Issue' && (ctx.payload.data?.labels ?? []).some((l) => l.name === 'ready-for-agent'),
  transform: (ctx) => ({
    startWorkflow: {
      name: 'ticket-to-pr',
      input: { ticketId: ctx.payload.data.identifier, title: ctx.payload.data.title },
      idempotencyKey: `linear:${ctx.payload.data.identifier}:ready-for-agent`,
    },
  }),
});
```

## Budgets and policy hooks

* `budget.maxCredits`, `maxSteps` and `maxDurationSeconds` on the definition are the per-run defaults; `Workflows.start` and `lua workflows start --budget-credits` can lower or override them. When a dimension runs out the run parks on a `budget` gate and can be raised (`raiseBudget`, or the desktop).
* `concurrencyPolicy: 'forbid'` is the overlap guard for scheduled workflows.
* `sideEffects: 'external'` plus `onError: 'park'` is the pattern for money-moving or PR-opening steps: a platform fault never re-runs them and a final failure waits for a person.
* `outputVisibility` restricts who can read a workflow's outputs; readers without access see `restricted: true`.
* `toolScope` on agent steps is mandatory when the prompt carries external content.

### Per-environment values

`env.template('KEY')` is resolved from the target environment when you `lua push workflow`, so staging and production can route to different agents or timezones from one source file:

```typescript theme={null}
createWorkflow({
  name: 'vendor-invoices',
  inputSchema: z.object({ batchId: z.string() }),
  schedule: { type: 'cron', expression: '0 6 * * 1-5', timezone: env.template('FINANCE_TZ') },
})
  .agentStep('policyCheck', { agentId: env.template('FINANCE_AGENT_ID'), prompt: template('...') })
```

A missing key aborts the push before anything is sent. Keys that look like secrets (ending in `SECRET`, `TOKEN`, `KEY`, `PASSWORD`) are refused - read those with `env('KEY')` inside `execute` instead. `lua workflows env-overlay <name>` shows which keys a version carries and whether each is present (values are never printed).

## Build errors

`commit()` and `lua compile` refuse graphs that cannot run. The most common codes:

| Code                                                          | Cause                                                                                |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `duplicate-step-id` / `unknown-step-ref`                      | A step id declared twice, or a string ref that names nothing in the chain.           |
| `map-id-required`                                             | Two or more `map()` calls without explicit ids.                                      |
| `closure-predicate` / `closure-binding`                       | A function where a predicate, template or mapping was expected.                      |
| `timeout-exceeds-tier`                                        | `timeoutSeconds` above 600 on a worker-tier step - add `tier: 'job'`.                |
| `long-job-requires-workspace`                                 | A Job-tier agent step longer than 4 h without a workspace.                           |
| `workspace-not-declared` / `workspace-requires-job-tier`      | A step mounts a workspace the workflow does not declare, or is not `tier: 'job'`.    |
| `approval-inside-container`                                   | An approval or signal wait inside `parallel`, `foreach` or a loop.                   |
| `escalation-chain-too-long` / `escalation-chain-not-terminal` | More than 3 hops, or a chain that does not end in `deny`, `cancel-run` or `fail`.    |
| `four-eyes-requires-editable`                                 | `fourEyes` without `editable: true`.                                                 |
| `env-template-secret-key`                                     | `env.template()` on a secret-shaped key.                                             |
| `hitl-duration-defaulted` (warning)                           | The graph waits for people and `budget.maxDurationSeconds` was defaulted to 30 days. |

## Script form

Workflows can also be written as a plain JavaScript script in `src/workflows/<name>.workflow.script.js` that exports `meta` (`name`, `description`, `phases`, `concurrency`, `sampleArgs`) and uses `agent(...)`, `parallel(...)`, `foreach(...)`, `step(...)` and `log(...)` as top-level awaits. `lua push workflow` and `lua test workflow` accept both forms; the desktop shows a **Phases** view instead of the graph for script runs.

## Testing locally

`lua test workflow --name <name>` (or `lua workflows run <name>`) compiles the project and drives the graph offline. Agent steps are faked unless `--agents live`; every approval, input suspend and signal can be pre-answered:

| Flag                                                  | Purpose                                                                  |
| ----------------------------------------------------- | ------------------------------------------------------------------------ |
| `--input <json\|@file>`                               | Run input.                                                               |
| `--step-output <id=json>`                             | Complete a step with this output (repeatable).                           |
| `--approve <id[=@payload]>` / `--deny <id[=@reason]>` | Pre-answer an approval (repeatable).                                     |
| `--signal <name=json>`                                | Pre-supply a signal payload (repeatable).                                |
| `--from-run <runId>`                                  | Seed completed steps from a real run (`--force` when the graph changed). |
| `--record <dir>` / `--fixtures <dir>`                 | Record agent and tool outputs, then replay them.                         |
| `--park <id>`                                         | Simulate a platform-fault park of a step (repeatable).                   |
| `--fast-retries` / `--real-time`                      | Collapse backoff waits, or actually wait on sleeps and backoffs.         |
| `--step-wall <s>` / `--job-wall <s>`                  | Per-step wall (default 600) and the virtual wall for Job-tier steps.     |
| `--now <iso>`                                         | Virtual clock start.                                                     |
| `--artefacts-dir <dir>`                               | Back `ctx.artefacts.*` on disk.                                          |
| `--env <KEY=value>`                                   | Local `env.template()` overlay (repeatable).                             |
| `--ledger-out <file>`                                 | Write the in-memory ledger as JSON.                                      |
| `--max-ticks <n>`                                     | Script form: tick cap (default 64).                                      |
| `--json`                                              | Print the ledger envelope instead of the timeline.                       |

Give every predicate both truth values: the fake agent stub alone cannot reach a `gt(confidence, 0.6)` arm, so pass `--step-output` for the agent step that feeds it.

## Related

* [Workflows](/overview/workflows) - the concept
* [Job tier](/workflows/job-tier) - workspaces, coding turns, size classes
* [Runs and events](/workflows/runs-and-events) - statuses, the ledger, SSE
* [Workflows Command](/cli/workflows-command) - the CLI reference
