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

# Build and run your first workflow

> Define a lead-outreach workflow with an agent step and an approval, test it offline, deploy it, and approve a live run from the CLI

In this tutorial you build `lead-outreach`, a [workflow](/concepts/workflows) that loads a lead, asks the model to draft an intro email, waits for you to approve the draft, and records the send. At the end, a run on the platform has paused for your decision, you have approved it from the terminal, and `lua workflows status` shows every step completed with the output `{"sent":true}`. Allow 20 minutes. You need a project created with `lua init` and a user session from `lua auth configure` ([Install the CLI and sign in](/get-started/install)).

*Verified against lua-cli 3.33.0.*

## What you'll build

* A code step that loads a lead, and an agent step that drafts the email from that step's output.
* An approval that shows you the draft, lets you edit its subject and body, and denies itself after 48 hours.
* A code step that records the send once, even when the platform retries it.
* An offline run with the approval answered from a flag, then a pushed and deployed version.
* A live run you follow to the approval, approve from the CLI, and read the result of.

<Steps>
  <Step title="Create the workflow file">
    Create `src/workflows/lead-outreach.ts`. Two `createStep()` objects hold your code; the `createWorkflow()` chain places them around an agent step and an approval and ends in `.commit()`.

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

    const Lead = z.object({ email: z.string(), name: z.string(), company: z.string() });
    const Draft = z.object({ subject: z.string(), body: z.string() });

    const loadLead = createStep({
      id: 'loadLead',
      inputSchema: z.object({ leadId: z.string() }),
      outputSchema: Lead,
      timeoutSeconds: 60,
      async execute({ inputData, log }) {
        // Replace with a call to your CRM; the return value must match outputSchema.
        log(`loading ${inputData.leadId}`);
        return { email: 'ada@example.com', name: 'Ada Lovelace', company: 'Example Ltd' };
      },
    });

    const recordSend = createStep({
      id: 'recordSend',
      inputSchema: z.object({ approved: z.boolean(), editedPayload: Draft.optional() }).passthrough(),
      outputSchema: z.object({ sent: z.boolean() }),
      sideEffects: 'external',
      onError: 'park',
      async execute({ inputData, getInitData, getStepResult, once }) {
        if (!inputData.approved) return { sent: false };
        const { leadId } = getInitData<{ leadId: string }>();
        const draft = inputData.editedPayload ?? getStepResult<z.infer<typeof Draft>>('draftEmail');
        // once() runs the effect a single time per step occurrence, even across retries.
        await once(`send:${leadId}`, async () => {
          const entry = await Data.create('outreach-log', { leadId, ...draft, sentAt: Date.now() });
          return entry.id;
        });
        return { sent: true };
      },
    });

    export const leadOutreach = createWorkflow({
      name: 'lead-outreach',
      description: 'Load a lead, draft an intro email, ask for approval, record the send.',
      inputSchema: z.object({ leadId: z.string() }),
      outputSchema: z.object({ sent: z.boolean() }),
      budget: { maxCredits: 10, maxDurationSeconds: 3 * 24 * 3600 },
    })
      .then(loadLead)
      .agentStep('draftEmail', {
        agentId: '$self',
        toolScope: {},
        prompt: template(
          'Write a three-line intro email to ${stepResults.loadLead.name} at ${stepResults.loadLead.company}.'
        ),
        outputSchema: Draft,
      })
      .approval('reviewDraft', {
        title: 'Send this intro email?',
        details: template('To ${stepResults.loadLead.email}: ${stepResults.draftEmail.subject}'),
        approver: 'creator',
        timeoutHours: 48,
        onTimeout: 'deny',
        editablePaths: ['subject', 'body'],
      })
      .then(recordSend)
      .commit();
    ```

    Read it top to bottom. `loadLead` returns a `Lead`; the platform validates that return value against `outputSchema` after every attempt. The agent step `draftEmail` runs one turn of your own agent (`'$self'`) with no tools and must answer in the shape of `Draft`, because `outputSchema` is set. The `template()` string is rendered when the step is dispatched, so `${stepResults.loadLead.name}` is the loaded lead's name; keep it in single quotes so TypeScript never interpolates it. The approval shows the draft to whoever started the run and lets them edit `subject` and `body`; after 48 hours without a decision it counts as denied. `recordSend` receives the approval's output, not the draft: `approved` is `false` on a denial or a timeout, and the edited draft arrives as `editedPayload`, else the step reads the original with `getStepResult`. `sideEffects: 'external'` stops the platform from re-running the step after a fault, `onError: 'park'` parks it for a person instead, and `once()` keeps the record from being written twice. `budget` caps the run at 10 credits (one per agent step) and three days. With the file saved, `npx tsc --noEmit` reports no errors.
  </Step>

  <Step title="List the workflow on the agent">
    Only workflows listed on the `LuaAgent` are compiled.

    ```ts src/index.ts highlight={2,7} theme={null}
    import { LuaAgent } from 'lua-cli';
    import { leadOutreach } from './workflows/lead-outreach';

    export default new LuaAgent({
      name: 'outreach-orchestrator',
      persona: 'You are the outreach assistant for Acme. Draft short, specific intro emails.',
      workflows: [leadOutreach],
    });
    ```

    Run `lua compile --ci` and the summary counts the workflow:

    ```text Output theme={null}
    🔨 Compiling...🔄 Syncing YAML with manifest...ℹ️  Server sync skipped — nothing was sent to the server; `lua push` publishes.
    ✅ Compiled 2 primitives (1 agent, 1 workflow) in 523ms
    ✨ Tip: run `lua test` to verify your tools work locally before pushing.
    ```

    A refused graph fails here with the file, the line, and a code such as `closure-predicate`; the compile also warns when a workflow with an approval has no `budget.maxDurationSeconds`, which is why the file sets one.
  </Step>

  <Step title="Run it offline and approve the draft">
    `lua test workflow` drives the graph on your machine: your code steps run for real, the agent step returns a fake `Draft` shaped from its schema, and `--approve` answers the approval. `--ci` turns any prompt the driver would show into an error, so the command behaves the same in a script.

    ```bash theme={null}
    lua test --ci workflow --name lead-outreach --input '{"leadId":"lead_abc123"}' --approve reviewDraft
    ```

    ```text Output theme={null}
    📦 Compiling code first...
    🔨 Compiling...🔄 Syncing YAML with manifest...ℹ️  Server sync skipped — nothing was sent to the server; `lua push` publishes.
    ✅ Compiled 2 primitives (1 agent, 1 workflow) in 531ms

    🧭 Running workflow locally: lead-outreach
    [18:04:17] run local-1789236257700 · 4 planned step(s)
    [18:04:17] loadLead · log · loading lead_abc123
    [18:04:17] loadLead · completed
    [18:04:17] draftEmail · completed
    [18:04:17] reviewDraft · completed
    [18:04:17] recordSend · completed
    [18:04:17] run · completed

    Workflow returned: Object — fields: sent
    Output:
    { sent: true }
    …
    ```

    The `recordSend` step wrote one entry to the `outreach-log` collection on your agent, which is the effect this workflow exists for. The `loadLead · log` line is your `log()` call. The agent step is faked here; `--agents live` sends it to your agent for a real draft.
  </Step>

  <Step title="Run it again and deny">
    A denial is data, not an error: the run continues and `recordSend` returns without writing.

    ```bash theme={null}
    lua test --ci workflow --name lead-outreach --input '{"leadId":"lead_abc123"}' --deny reviewDraft
    ```

    ```text Output theme={null}
    🧭 Running workflow locally: lead-outreach
    [18:04:20] run local-1789236260458 · 4 planned step(s)
    [18:04:20] loadLead · log · loading lead_abc123
    [18:04:20] loadLead · completed
    [18:04:20] draftEmail · completed
    [18:04:20] reviewDraft · completed
    [18:04:20] recordSend · completed
    [18:04:20] run · completed

    Workflow returned: Object — fields: sent
    Output:
    { sent: false }
    …
    ```

    Both paths of the graph now pass without a server. In a script, an approval you forgot to answer fails with exit 2 instead of waiting for a keyboard.
  </Step>

  <Step title="Push a version">
    Pushing creates a version of the workflow on your agent and records it in `lua.skill.yaml` under `workflows:`. Nothing runs it yet.

    ```bash theme={null}
    lua push workflow --name lead-outreach --ci --force
    ```

    The output names the version it created, `1.0.0` for a first push; `--set-version 1.1.0` chooses one, and `lua workflows versions lead-outreach` lists every version with a star on the active one. Workflows are not part of `lua push all`; push each one by name.
  </Step>

  <Step title="Deploy it">
    Deploying makes the pushed version the one that runs.

    ```bash theme={null}
    lua workflows deploy lead-outreach -v latest
    ```

    The command prints `✅ Version 1.0.0 of "lead-outreach" deployed` and `Workflow is live.`, and `lua workflows list` shows `1.0.0` in the `Active` column. Because the deploy also records an [agent version](/concepts/releases-and-versions) scoped to this workflow, `lua version promote <n>` is your rollback; the full flow is in [Release an agent to production](/ship/releasing).

    <Warning>
      The version is live for every trigger, schedule, and `Workflows.start()` call on this agent from now on; runs already in flight finish on the version they started with. `lua deploy` has no workflow type; `lua workflows deploy` is how a workflow goes live.
    </Warning>
  </Step>

  <Step title="Start a run and follow it to the approval">
    `start` creates a run and returns its id; `--follow` streams its events.

    ```bash theme={null}
    lua workflows start lead-outreach --input '{"leadId":"lead_abc123"}' --follow --timeout 600
    ```

    The first line is `✅ Run <runId> · queued`, then one line per event (`run.started`, `step.completed · loadLead`, `step.completed · draftEmail`) until the run parks on the approval. The stream ends with exit code 8 and the notice `⏸️  run waits for a person (approval · reviewDraft) — lua workflows approve <runId> --approval <id> --decision approve|deny; then: lua workflows watch <runId>`. Copy the run id. Had nobody decided within `--timeout`, the command would have exited 7 with the run still parked; `--idempotency-key <key>` on `start` makes a repeated command return this run instead of starting another.
  </Step>

  <Step title="Approve from the CLI">
    An approval is answered by its own id (`wfa_…`), never by the step id. Read it from the run, then decide.

    ```bash theme={null}
    lua workflows status <runId> --json | jq -r '.data.suspendedFor.approvalId'
    lua workflows approve <runId> --approval <approvalId> --decision approve --note "Go ahead"
    ```

    The second command prints the outcome and the run's new status, `running`; the note lands in the approval's output as `text`, where `recordSend` could read it. To change the draft before sending, read it with `lua workflows approval-payload <runId> --approval <approvalId>` and pass your edit with `--edit @draft.json --fingerprint <fingerprint>`; see [Add approvals and signals](/build/workflows/approvals-and-signals).
  </Step>

  <Step title="Read the result">
    Re-attach and wait for the terminal event, then read the run with its steps.

    ```bash theme={null}
    lua workflows watch <runId>
    lua workflows status <runId> --steps
    ```

    `watch` prints `step.completed · recordSend` and `run.completed`, and exits 0. The status header shows `Run <runId> · completed`, `Trigger:  api`, the budget spent (`0 of 10 credits`), and `Output:   {"sent":true}`; the table that follows lists `loadLead`, `draftEmail`, `reviewDraft`, and `recordSend`, each `completed` on attempt 1. Add `--json` to read every step's input and output preview as a document.
  </Step>
</Steps>

<Check>
  `lua workflows runs --workflow lead-outreach` lists your run with `Status` `completed` and `Trigger` `api`.
</Check>

## What you learned

* A workflow is a static graph: `createStep` holds your code, the chain places steps, and `.commit()` validates it before anything runs. [About workflows](/concepts/workflows) explains runs, steps, and containers.
* Data flows by binding, not by closure: `template()` placeholders and the previous step's output are how a step sees upstream results. [Author a workflow](/build/workflows/authoring) covers maps, predicates, and placement.
* An approval's output is the decision, not the payload, and a denial is data the next step reads. [Add approvals and signals](/build/workflows/approvals-and-signals) has every approver and timeout option.
* The offline driver runs your code for real and fakes only the model, so `--approve` and `--deny` cover both branches before you push. [Test a workflow offline](/build/workflows/test-offline) goes further.
* A workflow is pushed and deployed on its own, and a run is operated by id: `start`, `watch`, `status`, `approve`. [Operate runs](/build/workflows/operate-runs) covers retries, budgets, and cancellation.

## Next steps

<Columns cols={2}>
  <Card title="Operate runs" href="/build/workflows/operate-runs">Watch, repair, raise budgets, and cancel runs on the platform.</Card>
  <Card title="Workflow builder reference" href="/reference/sdk/workflow-builder">Every chain method, option, and build error.</Card>
  <Card title="Use the Job tier" href="/build/workflows/job-tier">Give a step a repository checkout and a coding turn.</Card>
</Columns>
