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

# Build workflows

> Define workflows and compose actions, agents, and durable steps.

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

## Build a simple workflow

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

**1. Define the workflow**

A workflow needs a `slug`, `name`, `description`, an `input` schema, and an `output` schema. Chain its steps, then call `.commit()`. Create a file in `src/workflows/` that default-exports the committed workflow.

```ts src/workflows/greeting.ts theme={null}
import { createStep, defineWorkflow } from "@meetluno/luno/workflow";
import { z } from "zod";

const Input = z.object({ name: z.string() });
const Output = z.object({ message: z.string() });

export default defineWorkflow({
  slug: "greeting",
  name: "Greeting",
  description: "Returns a short hello message for a name.",
  input: Input,
  output: Output,
})
  .then(
    createStep({
      id: "say-hello",
      inputSchema: Input,
      outputSchema: Output,
      execute: async ({ inputData }) => ({ message: `Hello, ${inputData.name}` }),
    }),
  )
  .commit();
```

The `slug` is the stable identity Luno OS uses for discovery, the `POST /workflows/{slug}` route, CLI commands, and run history.

**2. Deploy it**

Ship the project to the platform so the workflow runs in the cloud.

```bash theme={null}
luno deploy --project <project-slug>
```

Deploy builds and uploads your project, and the CLI now targets it automatically. See [deploy a project](https://docs.os.meetluno.ai/learn/projects/deploy-a-project.md) for project setup.

**3. Run it**

Invoke the workflow by its slug with a JSON input against your deployed project.

```bash theme={null}
luno workflows run greeting --input '{"name":"Ada"}'
```

The input is validated against the `input` schema and the result against `output`. For repeatable checks, add a [test](https://docs.os.meetluno.ai/learn/workflows/test-workflows.md) that runs the workflow and asserts its output.

**4. Run it from a trigger**

In production, workflows usually run from a [trigger](https://docs.os.meetluno.ai/learn/triggers/overview.md) (a webhook, schedule, or poll attached to the workflow) or as an [agent tool](https://docs.os.meetluno.ai/learn/workflows/run-workflows.md#run-workflows-as-an-agent-tool). You can also keep invoking it from the CLI or HTTP API; see [run workflows](https://docs.os.meetluno.ai/learn/workflows/run-workflows.md).

That is a complete, working workflow. The rest of this page is the full reference for building one.

## Configuration reference

`defineWorkflow()` accepts these options. The required fields are all you need to start; the rest are optional.

| Option | Required | What it does |
| - | - | - |
| `slug` | Yes | Stable identity used for discovery, the `POST /workflows/{slug}` route, CLI commands, and history |
| `name` | Yes | Human-readable name shown in the platform (a string or `{ en, es }`) |
| `description` | Yes | Short description shown in the platform (a string or `{ en, es }`) |
| `input` | Yes | Zod schema for the payload passed into the run (the first step's input) |
| `output` | Yes | Zod schema for the run's result (the last step's output) |
| `retries` | No | `{ attempts?, delay? }` default retries for every step. See [Durability and retries](#durability-and-retries). |
| `subscription` | No | `{ mode: "subscribable" }` to enable per-user fan-out. See [Subscriptions](#subscriptions). |
| `state` / `initialState` | No | Zod schema and starting value for run state shared by every step. See [Workflow state](#workflow-state). |
| `schedule` | No | Run the workflow on a cron schedule without a separate trigger file. See [Schedule](#schedule). |

`defineWorkflow()` returns a builder. Add steps with the methods below, then finish with `.commit()`. The committed workflow is what you default-export. `.commit()` is a type error when the last step's output does not match `output`, so add a final [`.map()`](#reshape-data-with-map) when the shapes differ.

> [!NOTE]
>
> Workflow definitions do not have a `credentials` option. Credentials are declared on the actions a workflow uses, then resolved per step when those steps run. See [credentials](https://docs.os.meetluno.ai/learn/credentials/overview.md).

## Steps

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

| Step | What it does |
| - | - |
| An action | `.then(myAction)` runs a [`defineAction`](https://docs.os.meetluno.ai/learn/actions/overview) as a step |
| `createStep({ id, inputSchema, outputSchema, execute })` | A custom step with its own typed logic |
| `agentStep(agent, { prompt, output? })` | Prompts an [agent](#agent-steps) |
| `llmStep(id, { prompt, model, outputSchema? })` | One [LLM call](#llm-steps) without a full agent |
| `workflowStep(child, { input? })` | Runs another workflow as a [sub-workflow](#sub-workflow-steps) |
| `hookStep(id, { schema? })` | Waits for an external [resume](#hooks) |

### Action steps

The most common step is an [action](https://docs.os.meetluno.ai/learn/actions/overview.md). Import the action and pass it to `.then()`. The same action works as a workflow step or an agent tool. Use `.map()` when one step's output doesn't match the next step's input.

```ts theme={null}
import { defineWorkflow } from "@meetluno/luno/workflow";
import { z } from "zod";
import { researchSignup } from "../actions/research-signup";
import { postBrief } from "../actions/post-brief";

export default defineWorkflow({
  slug: "signup-pipeline",
  name: "Signup pipeline",
  description: "Researches a signup and posts a brief.",
  input: z.object({ name: z.string(), email: z.string().email() }),
  output: z.object({ posted: z.boolean() }),
})
  .then(researchSignup) // { name, email } → { brief }
  .map(({ inputData }) => ({ text: inputData.brief }))
  .then(postBrief) // { text } → { posted }
  .commit();
```

An action step's id is its slug. To use the same action twice in one workflow, give the second use its own id with `.as("id")`; to pin its credential scope, use `.scope("user")`:

```ts theme={null}
  .then(double)
  .then(double.as("double-again"))
  .then(postBrief.scope("user"))
```

### Custom steps

When a step needs logic of its own, write it with `createStep()`. Declare its input and output schemas and an `execute` function. Inside `execute`, you can also call an action directly with `await myAction.run(input)`; its credentials bind to the enclosing step.

```ts theme={null}
import { createStep } from "@meetluno/luno/workflow";
import { z } from "zod";

const buildPrompt = createStep({
  id: "build-prompt",
  inputSchema: z.object({ subject: z.string(), sender: z.string() }),
  outputSchema: z.object({ prompt: z.string() }),
  execute: async ({ inputData }) => ({
    prompt: `Summarize this inbox item in one sentence.\nFrom: ${inputData.sender}\nSubject: ${inputData.subject}`,
  }),
});
```

Prefer chaining actions as steps over calling them inside a custom step: each chained action is its own recorded step and its own node on the canvas.

### Agent steps

When a step needs judgment or open-ended tool use, prompt an [agent](https://docs.os.meetluno.ai/learn/agents/overview.md) with `agentStep()`. The prompt runs as a queued child session that you can inspect; the workflow suspends and resumes when the child completes. `prompt` is a string or a function of the step's input.

```ts theme={null}
import { agentStep, defineWorkflow } from "@meetluno/luno/workflow";
import { z } from "zod";
import support from "../agents/support";

type Item = { subject: string; sender: string };

export default defineWorkflow({
  slug: "summarize-inbox",
  name: "Summarize inbox",
  description: "Asks support to summarize an inbox item in one sentence.",
  input: z.object({ subject: z.string(), sender: z.string() }),
  output: z.object({ summary: z.string() }),
})
  .then(
    agentStep(support, {
      // Annotating inputData types the step's input; `.then` checks it against the previous step.
      prompt: ({ inputData }: { inputData: Item }) =>
        `Summarize this inbox item in one sentence.\nFrom: ${inputData.sender}\nSubject: ${inputData.subject}`,
    }),
  )
  .map(({ inputData }) => ({ summary: inputData.text }))
  .commit();
```

Without `output`, the step returns `{ text, sessionId }`. Pass a Zod schema as `output` to get a validated, structured reply instead. The step id defaults to the agent slug; set `id` to prompt the same agent twice, and `sessionId` to continue an existing conversation.

In Studio, the step's **Config** tab has a **This step** section that overrides the agent for that step only: extra instructions (appended to the agent's prompt), model, autonomy, max steps, and tool switches. It is versioned like the agent's own [dashboard overrides](https://docs.os.meetluno.ai/learn/agents/build-agents.md#dashboard-overrides) and merged on top of them. A step can only be stricter: it cannot turn back on a tool the agent turned off, or set **Acts on its own** under an agent that **Always asks**.

An agent that only runs inside workflow steps (an extractor, a classifier) is not one people should chat with: set `chat: false` on its `defineAgent` and the user view leaves it out of the Space's chat list. Studio still lists it.

Mix deterministic action steps with agent steps freely. A common shape is to run actions to gather data, prompt an agent to make a decision or draft text, then run more actions to act on the result.

### LLM steps

For a one-shot model call without defining a full agent, use `llmStep()`. It returns the model's text, or a validated, structured result when you pass an `outputSchema`.

```ts theme={null}
import { defineWorkflow, llmStep } from "@meetluno/luno/workflow";
import { z } from "zod";

const Category = z.object({
  category: z.enum(["pun", "dad-joke", "one-liner", "observational", "absurdist"]),
});

export default defineWorkflow({
  slug: "joke-flow",
  name: "Joke flow",
  description: "Generates a joke and classifies its category.",
  input: z.object({}),
  output: z.object({ joke: z.string(), category: Category.shape.category }),
})
  .then(
    llmStep("joke", {
      prompt: "Tell me a funny joke. Reply with only the joke.",
      model: "google/gemini-3.5-flash",
    }),
  )
  .then(
    llmStep("classify", {
      prompt: ({ inputData }: { inputData: string }) => `Classify this joke:\n\n${inputData}`,
      model: "google/gemini-3.5-flash",
      outputSchema: Category,
    }),
  )
  .map(({ inputData, getStepResult }) => ({
    joke: z.string().parse(getStepResult("joke")),
    category: inputData.category,
  }))
  .commit();
```

Use `llmStep()` for simple text generation or classification inside a workflow, and a full [agent step](#agent-steps) when the step needs tools, memory, or multi-turn reasoning. The `model` string follows the same rules as agents — copy an exact id from the [models catalog](https://os.meetluno.ai/models.md) (see [Models](https://docs.os.meetluno.ai/learn/agents/build-agents.md#models)).

When an `outputSchema` has multiple result shapes, model it as a discriminated union rather than one flat object full of optional/nullable fields — it is more type-safe and avoids a provider union-parameter limit. See [structured output schema design](https://docs.os.meetluno.ai/learn/agents/build-agents.md#schema-design-model-the-shape-precisely).

### Sub-workflow steps

To reuse a whole workflow, import it and wrap it in `workflowStep()`. Luno OS validates the sub-workflow's `input`/`output`, records its result, and gives it its own trace span. Without `input`, the previous step's output is passed through; pass `input` to build the child's input from it.

```ts theme={null}
import { defineWorkflow, workflowStep } from "@meetluno/luno/workflow";
import { z } from "zod";
import enrichLead from "./enrich-lead";

export default defineWorkflow({
  slug: "signup-pipeline",
  name: "Signup pipeline",
  description: "Enriches a lead via a sub-workflow and returns a score.",
  input: z.object({ email: z.string().email() }),
  output: z.object({ score: z.number() }),
})
  .then(workflowStep(enrichLead)) // enrich-lead: { email } → { score, … }
  .map(({ inputData }) => ({ score: inputData.score }))
  .commit();
```

The step id defaults to the child's slug; set `id` to run the same child twice. The sub-workflow behavior depends on where you run it:

* **Deployed / queued runtime** — the child is a **queued child run** linked to its parent. The parent suspends with status `waiting_children` until the child completes, then continues with the child's output. The child can use its own durable waits, appears separately in run history, and counts toward organization concurrency.
* **Local `executeWorkflow` (tests)** — the child runs **inline** in the same process (no separate child run, and it cannot suspend).

Inspect a parent and its children in one command:

```bash theme={null}
luno workflows runs get <parentRunId> --include steps,trace,children
```

## Control flow

Branches, loops, and parallelism are builder methods, so they appear as real structure on the [workflow canvas](https://docs.os.meetluno.ai/learn/workflows/authoring-best-practices.md).

### Reshape data with map

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

```ts theme={null}
  .map(
    ({ inputData, getInitData }) => ({
      message: getInitData().message,
      priority: inputData.priority,
    }),
    { id: "build-ticket" },
  )
```

### Branch

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

```ts theme={null}
  .then(triage) // → { priority, message }
  .branch([
    [async ({ inputData }) => inputData.priority === "urgent", pageOnCall],
    [async ({ inputData }) => inputData.priority !== "urgent", queueForReview],
  ])
  .map(({ inputData }) => ({ paged: inputData["page-on-call"] !== undefined }))
```

### Loops

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

```ts theme={null}
  // Process every item, five at a time.
  .map(({ inputData }) => inputData.items) // → Item[]
  .foreach(processItem, { concurrency: 5 }) // → ProcessedItem[]
```

```ts theme={null}
  // Poll an export until it is ready.
  .dountil(checkExportStatus, async ({ inputData }) => inputData.status === "ready")
```

### Errors

A step that throws fails the run (after its [retries](#durability-and-retries)). To handle a failure yourself instead, catch it inside a [custom step](#custom-steps):

```ts theme={null}
const charge = createStep({
  id: "charge",
  inputSchema: z.object({ id: z.string(), amount: z.number() }),
  outputSchema: z.object({ charged: z.boolean() }),
  execute: async ({ inputData }) => {
    try {
      await chargeCard.run({ amount: inputData.amount });
      return { charged: true };
    } catch {
      await notifyBillingFailed.run({ id: inputData.id });
      return { charged: false };
    }
  },
});
```

## Parallel steps

`.parallel([...])` runs independent steps concurrently on the same input. Each one is still its own recorded step, and the output is an object keyed by step id:

```ts theme={null}
  .parallel([enrichLead, scoreLead]) // both receive { leadId }
  .map(({ inputData }) => ({
    company: enrichLead.output.parse(inputData["enrich-lead"]).company,
    score: scoreLead.output.parse(inputData["score-lead"]).score,
  }))
```

An action's step id is typed as a plain `string`, so the keyed output is loosely typed; re-validate each entry with the action's `output` schema as above (`agentStep`, `llmStep`, `hookStep`, and `workflowStep` ids are literal types). Give steps distinct ids when the same step appears more than once (`.as("id")` on actions, `id` on `agentStep` / `workflowStep`). For a dynamic number of items, use [`.foreach()`](#loops) with a `concurrency` limit.

## Council

A council asks several agents the same question independently and in parallel, then has one agent combine their answers. Use it when independent perspectives (different prompts, models, or tools) beat a single answer: reviews, risk assessments, estimates. Pass `councilStep()` to `.then()`:

```ts theme={null}
import { councilStep, defineWorkflow } from "@meetluno/luno/workflow";
import { z } from "zod";
import judge from "../agents/judge";
import optimist from "../agents/optimist";
import skeptic from "../agents/skeptic";

const verdict = z.object({ ship: z.boolean(), reasons: z.array(z.string()) });

export default defineWorkflow({
  slug: "launch-review",
  name: "Launch review",
  description: "Asks a council of agents whether a feature is ready to ship.",
  input: z.object({ spec: z.string() }),
  output: verdict,
})
  .then(
    councilStep("launch-council", {
      members: [optimist, skeptic],
      prompt: ({ inputData }: { inputData: { spec: string } }) =>
        `Is this feature ready to ship?\n\n${inputData.spec}`,
      synthesize: judge,
      output: verdict,
    }),
  )
  .commit();
```

| Option | Description |
| - | - |
| `members` | 2 to 8 distinct agents. Each runs as an [agent step](#agent-steps) with id `<id>-<agent slug>`, all in one parallel group. |
| `prompt` | The prompt every member receives: a string or a function of the council's input. |
| `synthesize` | The agent that combines the answers. Its step id is the council `id`, and its reply is the council's output. |
| `synthesisPrompt` | Optional `(answers, input) => string`. `answers` is `[{ agent, text }]` in member order; `input` is the workflow run input. The default prompt lists every answer under its agent slug and asks for one combined answer. |
| `output` | Optional Zod schema for a structured synthesis. Without it the council returns `{ text, sessionId }`. |

A council is durable like any agent step: on deployed projects each member runs as its own queued child session and the run resumes when all of them finish. The canvas shows the members in a parallel group followed by the synthesis agent.

## Durability and retries

Workflows are durable. As each step completes, its result is recorded with the run. If a step fails and the run is retried, it resumes from the failed step: completed steps keep their results and do not run again. A run interrupted by a crash or deploy resumes from the step that was in flight.

This has two practical consequences for how you write steps:

* **Put side effects inside steps.** Network calls, writes, and other side effects belong in actions or custom steps, where they are recorded once. `.map()` and branch conditions are for pure data shaping and decisions.
* **Steps should be idempotent where possible.** A step can be retried after a transient failure, so design actions to tolerate being called more than once with the same input.

Steps retry in place according to `retries: { attempts, delay }` on `defineWorkflow()` (the default for every step; `createStep({ retries })` overrides it per step). On top of that, a failed run is re-enqueued by the queue (with backoff) and resumes from the failed step. The runtime records `step_completed`, `step_retrying`, and `step_failed` events as the run progresses, and surfaces them in [workflow runs](https://docs.os.meetluno.ai/learn/logs/workflow-runs.md).

There is no built-in saga or compensation engine. When a later step fails and you need to undo earlier work, orchestrate it yourself inside a custom step with `try`/`catch` (see [Errors](#errors)) and return a flag rather than rethrowing once you've handled it. If you let the error propagate instead, the run fails and the queue retries it — fine for transient failures, but any cleanup you add must itself be idempotent.

## Workflow state

Steps pass data to each other through their inputs and outputs. For values many steps need (a counter, accumulated results, configuration), declare run **state** instead: a Zod `state` schema and an optional `initialState` (validated when the workflow is defined; schema defaults apply).

```ts theme={null}
const progress = z.object({ processed: z.array(z.string()) });

const markDone = createStep({
  id: "mark-done",
  inputSchema: z.object({ id: z.string() }),
  outputSchema: z.object({ id: z.string() }),
  stateSchema: progress,
  execute: async ({ inputData, state, setState }) => {
    await setState({ processed: [...state.processed, inputData.id] });
    return inputData;
  },
});

export default defineWorkflow({
  slug: "process-item",
  name: "Process item",
  description: "Processes one item and tracks progress.",
  input: z.object({ id: z.string() }),
  output: z.object({ count: z.number() }),
  state: progress,
  initialState: { processed: [] },
})
  .then(markDone)
  .sleep("1h")
  .map(({ state }) => ({ count: state.processed.length }))
  .commit();
```

`createStep` steps read `state` and call `setState` (declare `stateSchema` on the step for typed state); `.map()` gets typed `state` and `setState`, and branch and loop conditions get typed `state`. `setState` replaces the whole state, so spread the current value to keep other fields. State is saved with the run: it survives sleeps, hooks, retries, crash restarts and time travel.

## Schedule

To run a workflow on a timer, add `schedule` instead of writing a separate [schedule trigger](https://docs.os.meetluno.ai/learn/triggers/schedules.md). `input` is the run input on each tick and is typed from the workflow `input` schema.

```ts theme={null}
export default defineWorkflow({
  slug: "daily-report",
  // ...
  schedule: { cron: "0 9 * * 1-5", timezone: "Europe/Madrid", input: { region: "eu" } },
});
```

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

## Durable waits

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

### Sleep

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

```ts theme={null}
export default defineWorkflow({
  slug: "delayed-followup",
  name: "Delayed followup",
  description: "Sends welcome email, waits an hour, then sends a followup.",
  input: z.object({ email: z.string().email() }),
  output: z.object({ sent: z.boolean() }),
})
  .then(sendWelcome) // { email } → { email }
  .sleep("1h")
  .then(sendFollowup) // { email } → { sent }
  .commit();
```

### Hooks

`hookStep()` creates a resume token and a URL, then suspends the run until something calls back. Use it for human-in-the-loop approvals or waiting on an external system. The resume payload becomes the step's output.

```ts theme={null}
import { defineWorkflow, hookStep } from "@meetluno/luno/workflow";
import { z } from "zod";

export default defineWorkflow({
  slug: "approval-flow",
  name: "Approval flow",
  description: "Requests approval and waits for the decision.",
  input: z.object({ id: z.string() }),
  output: z.object({ approved: z.boolean() }),
})
  .then(requestApproval) // { id } → { id }
  .then(
    hookStep("approval", {
      schema: z.object({ approved: z.boolean() }),
      // Send the link by email or chat; anyone holding it can resume the run.
      onSuspend: ({ resumeUrl }) => console.info(`Approve or deny: ${resumeUrl}`),
    }),
  )
  .commit();
```

`onSuspend` runs once when the run starts waiting and receives the step input, the `token`, and the `resumeUrl`. Hand the token or URL to whatever will resume the run, such as an approval email link or a Slack button. The payload posted back (or passed as query params on `GET`) is validated by the optional `schema` and becomes the step's output. Set `token` for a fixed token instead of a random one. See [resume a suspended run](https://docs.os.meetluno.ai/learn/workflows/run-workflows.md#resume-a-suspended-run) for the HTTP call. While suspended, the run shows **Waiting for a response** in [run history](https://docs.os.meetluno.ai/learn/logs/overview.md).

Every hook also opens a task in [My tasks](https://docs.os.meetluno.ai/learn/web-app/user-view.md#my-tasks), where the assignee can answer it from the web app. Set `assignee` (an email, a Luno user id, `{ group: "<permission group>" }`, a list of those, or a function of the step input), `title` (default `Review <step id>`) and `dueIn` (shown as the due date; it never resolves the hook). A hook whose `schema` asks for a boolean `approved` (like the example above) shows as an approval, answered with **Approve** / **Reject**, and its default title is `Approve <step id>`. Without `assignee`, the task goes to the `responders`, then the workflow's owner, then the project admins.

#### Hooks answered by people outside the project

New hires, external authors and customers are not project members, so give the hook `responders`. Each responder gets their own signed link to a hosted form built from the hook's `schema`. The link is bound to the project, the hook and that email, expires, and works once (the first answer closes the hook for everyone). The raw `resumeUrl` no longer resumes a responder hook.

```ts theme={null}
import { hookStep, isHookExpired } from "@meetluno/luno/workflow";
import { z } from "zod";

hookStep("intake", {
  schema: z.object({
    bio: z.string().max(1000).describe("A few lines about you"),
    team: z.enum(["eng", "sales"]),
    remote: z.boolean(),
  }),
  responders: { email: (input: { email: string }) => input.email }, // or { emails: ["a@b.com"] }
  expiresIn: "3d", // default 7 days for responder hooks; a Date also works
  onSuspend: async ({ responderLinks }) => {
    for (const { email, url } of responderLinks ?? []) await sendEmail(email, url);
  },
});
// Next step: if (isHookExpired(answer)) { /* nobody answered in time */ }
```

The form shows one field per top-level schema property: text inputs for strings (a textarea when `.max()` is above 200), number inputs, checkboxes, selects for enums, and a JSON textarea for anything nested. Property `.describe()` text becomes the help line; `.meta({ title })` on the object becomes the page title. The page follows the visitor's language (English or Spanish). The answer is validated against the schema, and the responder's email is recorded on the hook (`responded_by`).

A hook with `expiresIn` (or `responders`) returns `{ expired: true }` when nobody answered by then, and the run continues. Its step output is typed `T | { expired: true }`; narrow it with `isHookExpired`. After expiry the resume URL and the links answer `410`.

> [!NOTE]
>
> Sleeps and hooks require queued execution. Trigger, CLI, API, sub-workflow, and agent-tool runs all use the queue, so they can suspend and resume normally.

## Run context

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

| Field | What it is |
| - | - |
| `runId` | The unique ID of this workflow run |
| `trigger` | How the run started: `api`, `cron`, `webhook`, `poll`, or `retry` |
| `triggeredAt` | When the run was triggered |
| `workspacesRoot` | Host directory backing agent sandboxes for steps that prompt agents |
| `subscriber` | On subscribable runs, who the run is for: `{ userId, email, name?, timezone }` (timezone: subscription → profile → schedule → UTC), resolved server side |

```ts theme={null}
createStep({
  id: "build-prompt",
  inputSchema: z.object({ message: z.string() }),
  outputSchema: z.object({ prompt: z.string() }),
  execute: async ({ inputData }) => ({
    prompt: `${getWorkflowRunInfo().trigger ?? "api"}: ${inputData.message}`,
  }),
});
```

## Subscriptions

By default a workflow runs once per triggering event. Setting `subscription: { mode: "subscribable" }` makes a trigger fan out instead: when the source fires, Luno OS starts one run per enabled subscription. For personal accounts on those runs, pin `.scope("user")` and [assign](https://docs.os.meetluno.ai/learn/credentials/connect-credentials.md#bind-a-credential-to-a-step-tool-or-poll-action) each subscriber's user credential to the relevant steps.

```ts theme={null}
export default defineWorkflow({
  slug: "daily-digest",
  name: "Daily digest",
  description: "Sends a personal daily digest once per subscriber.",
  subscription: { mode: "subscribable" },
  input: z.object({}),
  output: z.object({ sent: z.boolean() }),
})
  // Runs once per subscriber, as that subscriber.
  .then(sendDigest.scope("user"))
  .commit();
```

Reach for this when one trigger should do per-person work, such as a personal daily digest or a per-user sync, rather than a single shared run. A subscribable workflow with no active subscriptions starts no runs.

### Per-subscriber schedules

Add `perSubscriber: true` to the schedule of a subscribable workflow to fire each subscriber at the cron's time in their own timezone, instead of everyone at once:

```ts theme={null}
export default defineWorkflow({
  slug: "standup",
  // ...
  subscription: { mode: "subscribable" },
  schedule: {
    cron: "0 9 * * 1-5",
    timezone: "Europe/Madrid", // fallback for subscribers with no timezone
    perSubscriber: true,
    respectWorkingHours: true,
    skipHolidays: true,
  },
});
```

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

## Next steps

> **[Best practices](https://docs.os.meetluno.ai/learn/workflows/authoring-best-practices.md)**
>
> Keep workflow steps small, typed, and safe to retry.

> **[Run workflows](https://docs.os.meetluno.ai/learn/workflows/run-workflows.md)**
>
> Start runs from the CLI, triggers, the API, and agent tools.

> **[Test workflows](https://docs.os.meetluno.ai/learn/workflows/test-workflows.md)**
>
> Run workflows in tests and assert their output.

> **[Workflow runs](https://docs.os.meetluno.ai/learn/logs/workflow-runs.md)**
>
> Inspect input, output, steps, errors, and traces.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.