> ## Documentation Index
> Fetch the complete documentation index at: https://keystroke.ai/docs/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 `run` function that calls steps 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.

<Steps>
  <Step title="Define the workflow">
    A workflow needs a `slug`, `name`, `description`, an `input` schema, an `output` schema, and a `run` function. Create a file in `src/workflows/` that default-exports the definition.

    ```ts src/workflows/greeting.ts theme={null}
    import { defineWorkflow } from "@keystrokehq/keystroke/workflow";
    import { z } from "zod";

    export default defineWorkflow({
      slug: "greeting",
      name: "Greeting",
      description: "Returns a short hello message for a name.",
      input: z.object({ name: z.string() }),
      output: z.object({ message: z.string() }),
      async run(input) {
        return { message: `Hello, ${input.name}` };
      },
    });
    ```

    The `slug` is the stable identity Keystroke uses for discovery, the `POST /workflows/{slug}` route, CLI commands, and run history.
  </Step>

  <Step title="Deploy it">
    Ship the project to the platform so the workflow runs in the cloud.

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

    Deploy builds and uploads your project, and the CLI now targets it automatically. See [deploy a project](/docs/learn/projects/deploy-a-project) for project setup.
  </Step>

  <Step title="Run it">
    Invoke the workflow by its slug with a JSON input against your deployed project.

    ```bash theme={null}
    keystroke 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](/docs/learn/workflows/test-workflows) that runs the workflow and asserts its output.
  </Step>

  <Step title="Run it from a trigger">
    In production, workflows usually run from a [trigger](/docs/learn/triggers/overview) (a webhook, schedule, or poll attached to the workflow) or as an [agent tool](/docs/learn/workflows/run-workflows#run-workflows-as-an-agent-tool). You can also keep invoking it from the CLI or HTTP API; see [run workflows](/docs/learn/workflows/run-workflows).
  </Step>
</Steps>

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                                                         |
| `description`  | Yes      | Short description shown in the platform                                                           |
| `input`        | Yes      | Zod schema for the payload passed into the run                                                    |
| `output`       | Yes      | Zod schema for the value `run` returns                                                            |
| `run`          | Yes      | `async (input, ctx) => output`, the orchestration body                                            |
| `subscription` | No       | `{ mode: "subscribable" }` to enable per-user fan-out. See [Subscriptions](#subscriptions).       |

The `run` function receives the validated `input` and a [run context](#the-run-context) (`ctx`). Whatever it returns is validated against `output`.

<Note>
  Workflow definitions do not have a `credentials` option. Credentials are declared on the actions a workflow calls, then resolved when those steps run. See [credentials](/docs/learn/credentials/overview).
</Note>

## Steps

Inside `run`, each call to an action, agent, or LLM is a durable step. Steps run in the order you `await` them, and their results are recorded so a retry can skip work that already succeeded.

A workflow can also be a step. Import another workflow and `await otherWorkflow.run(input)` inside your `run` body — see [Sub-workflow steps](#sub-workflow-steps). The child has its own tracked run, while the parent waits durably for its result.

### Action steps

The most common step is an [action](/docs/learn/actions/overview). Import the action and call `.run()` with its input. The same action works as a workflow step or an agent tool.

```ts theme={null}
import { defineWorkflow } from "@keystrokehq/keystroke/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({ brief: z.string() }),
  async run(input) {
    const { brief } = await researchSignup.run(input);
    await postBrief.run({ text: brief });
    return { brief };
  },
});
```

Integration packages export actions too; import only the ones the workflow should use, for example `slackSendMessage` from `@keystrokehq/slack/actions`.

### Agent steps

When a step needs judgment or open-ended tool use, prompt an [agent](/docs/learn/agents/overview) with `.prompt()`. The prompt runs as a queued child session that you can inspect; the workflow suspends and resumes when the child completes.

```ts theme={null}
import { defineWorkflow } from "@keystrokehq/keystroke/workflow";
import { z } from "zod";
import support from "../agents/support";

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() }),
  async run(input) {
    const result = await support.prompt({
      message: `Summarize this inbox item in one sentence.\nFrom: ${input.sender}\nSubject: ${input.subject}`,
    });

    return { summary: result.text };
  },
});
```

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

### LLM steps

For a one-shot model call without defining a full agent, use `promptLlm()`. Pass an `outputSchema` to get a validated, structured result instead of raw text.

```ts theme={null}
import { defineWorkflow, promptLlm } from "@keystrokehq/keystroke/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 }),
  async run() {
    const joke = await promptLlm("Tell me a funny joke. Reply with only the joke.", {
      model: "google/gemini-3.5-flash",
    });

    const { category } = await promptLlm(`Classify this joke:\n\n${joke}`, {
      model: "google/gemini-3.5-flash",
      outputSchema: Category,
    });

    return { joke, category };
  },
});
```

Use `promptLlm()` 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://keystroke.ai/models.md) (see [Models](/docs/learn/agents/build-agents#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](/docs/learn/agents/build-agents#schema-design-model-the-shape-precisely).

### Sub-workflow steps

To reuse a whole workflow, import it and `await` its `.run(input)` — the same first-class, durable step shape as an action. Keystroke validates the sub-workflow's `input`/`output`, records its result as a checkpoint, and gives it its own trace span.

```ts theme={null}
import { defineWorkflow } from "@keystrokehq/keystroke/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() }),
  async run(input) {
    const enriched = await enrichLead.run({ email: input.email });
    return { score: enriched.score };
  },
});
```

The sub-workflow behavior depends on where you run it:

* **Deployed / queued runtime** — the child is a **queued child run** linked to its parent (`parentWorkflowRunId` / `child_dispatched`). The parent suspends with status `waiting_children` until the child completes, then replays from the child's checkpointed output. The child can use its own durable waits (`ctx.sleep` / `ctx.hook`), appears separately in run history, and counts toward organization concurrency.
* **Local `executeWorkflow` (tests)** — the child runs **inline** in the parent's event log (no separate child run). Steps inside the child are namespaced as `step:<child-slug>/<action-slug>#<n>` so they don't collide with the parent. See [Test workflows](/docs/learn/workflows/test-workflows#stub-steps-by-seeding-the-event-log).

Never pass `ctx` — `enrichLead.run(input, ctx)` is not the pattern.

Inspect a parent and its children in one command:

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

## Control flow

The `run` body is plain TypeScript, so you branch and loop with ordinary language features. Control flow decides which steps run; the durable engine records each step as it completes.

```ts theme={null}
async run(input, ctx) {
  const { priority } = await triage.run({ message: input.message });

  if (priority === "urgent") {
    await pageOnCall.run({ message: input.message });
  } else {
    await queueForReview.run({ message: input.message });
  }

  for (const item of input.items) {
    await processItem.run({ item });
  }

  return { handled: true };
}
```

Each step is recorded under a correlation id derived from **where the call appears in your workflow** and the order it runs (`triage#0`, `process-item#0`, `process-item#1`, and so on). Keystroke assigns these ids automatically from the call's structural position — there are no user-authored step ids. That id is what a retry replays against, so keep control flow deterministic: branch on the run's input and recorded step results, not on `Date.now()`, randomness, or other values that change between attempts.

Because the id is tied to the call's position (not just a running counter), adding or removing an unrelated step never shifts the ids of later steps, so replays stay aligned. This same structural id is what a step's node lights up against on the [workflow canvas](/docs/learn/workflows/authoring-best-practices), so writing control flow with plain `if` / `else` / `switch` and `for-of` keeps both replay and the canvas legible.

Wrap a step in `try`/`catch` to handle a failing step yourself instead of failing the whole run:

```ts theme={null}
try {
  await chargeCard.run({ amount });
} catch {
  await notifyBillingFailed.run({ id: input.id });
}
```

## Parallel steps

Independent steps run concurrently with `Promise.all`. Each `.run()`, `.prompt()`, or `promptLlm()` inside it is still its own durable step — recorded and replayed individually — so you get parallelism without giving up durability:

```ts theme={null}
const [enriched, scored] = await Promise.all([
  enrichLead.run({ leadId }),
  scoreLead.run({ leadId }),
]);
```

Correlation ids are assigned per call site in array order (`enrich-lead#0`, `score-lead#0`; repeats of the same call site get `#0`, `#1`, …). Because each id is tied to the call's structural position and disambiguated by occurrence, dynamic arrays and conditional branches replay consistently as long as the run stays deterministic — build the array from the run's input and recorded results, not from values that change between attempts:

```ts theme={null}
const results = await Promise.all(leads.map((lead) => enrichLead.run({ leadId: lead.id })));
```

This runs and replays correctly, but a computed `Promise.all(...map(...))` renders as a single opaque block on the canvas rather than distinct parallel branches. For a fixed set of steps, prefer a literal array (`Promise.all([a.run(), b.run()])`); for a dynamic set you also want visible on the canvas, use a `for-of` loop. See [Best practices](/docs/learn/workflows/authoring-best-practices) for the full set of canvas-legible patterns.

There is no built-in concurrency limit or fan-out helper — `Promise.all` starts everything at once. For bounded concurrency, batch the array yourself and `await` each batch.

## Durability and retries

Workflows are durable. As each step completes, its result is written to a run event log. If a later step fails and the run is retried, Keystroke replays the log: completed steps return their recorded result instead of running again, and execution resumes at the first step that has not finished.

This has two practical consequences for how you write `run`:

* **Put side effects inside steps.** Work done directly in the `run` body (not inside an action, agent, or `promptLlm` call) runs again on every replay. Keep network calls, writes, and other side effects inside `.run()` / `.prompt()` steps so they are recorded and not repeated.
* **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.

You generally do not manage retries yourself. Retries are **queue-level**: a failed run is re-enqueued (with backoff) and replays from the event log, so completed steps don't re-run. The runtime records `step_completed`, `step_retrying`, and `step_failed` events as the run progresses, and surfaces them in [workflow runs](/docs/learn/logs/workflow-runs).

There is no built-in saga or compensation engine. When a later step fails and you need to undo earlier work, orchestrate it yourself with `try`/`catch` — catching the error lets the run complete normally, so return a flag rather than rethrowing once you've handled it:

```ts theme={null}
async run(input) {
  await reserveBooking.run({ id: input.bookingId });
  try {
    await chargeCustomer.run({ id: input.bookingId });
    return { ok: true };
  } catch {
    await releaseBooking.run({ id: input.bookingId }); // best-effort cleanup
    return { ok: false, compensated: true };
  }
}
```

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 a recorded, idempotent step.

## Durable waits

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

### Sleep

`ctx.sleep()` suspends the run for a duration or until a `Date`, then resumes from the same point.

```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() }),
  async run(input, ctx) {
    await sendWelcome.run({ email: input.email });
    await ctx.sleep("1h");
    await sendFollowup.run({ email: input.email });
    return { sent: true };
  },
});
```

### Hooks

`ctx.hook()` 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.

```ts theme={null}
async run(input, ctx) {
  await requestApproval.run({ id: input.id });

  const decision = await ctx.hook<{ approved: boolean }>();

  return { approved: decision.approved };
}
```

The handle carries a `token` and a `resumeUrl`. Awaiting it suspends the run until something resumes the hook by that token; the payload posted back (or passed as query params on `GET`) becomes the resolved value (validated by an optional `schema`). Hand the token or URL to whatever will resume the run, such as an approval email link or a Slack button. See [resume a suspended run](/docs/learn/workflows/run-workflows#resume-a-suspended-run) for the HTTP call. While suspended, the run shows **Waiting on hook** in [run history](/docs/learn/logs/overview).

<Note>
  `ctx.sleep()` and `ctx.hook()` require queued execution. Trigger, CLI, API, sub-workflow, and agent-tool runs all use the queue, so they can suspend and resume normally.
</Note>

## Run context

`run(input, ctx)` receives a context object with run metadata and the durable wait primitives:

| 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                                          |
| `sleep`          | [Durable sleep](#sleep) for a duration or until a `Date`            |
| `hook`           | [Durable hook](#hooks) that suspends until resumed                  |
| `workspacesRoot` | Host directory backing agent sandboxes for steps that prompt agents |

## Subscriptions

By default a workflow runs once per triggering event. Setting `subscription: { mode: "subscribable" }` makes a trigger fan out instead: when the source fires, Keystroke starts one run per enabled subscription. For personal accounts on those runs, pin `.scope("user")` and [assign](/docs/learn/credentials/connect-credentials#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() }),
  async run(input, ctx) {
    // Runs once per subscriber, as that subscriber.
    return { sent: true };
  },
});
```

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.

## Next steps

<CardGroup cols={2}>
  <Card title="Best practices" href="/docs/learn/workflows/authoring-best-practices">
    Keep workflows replayable at runtime and legible on the canvas.
  </Card>

  <Card title="Run workflows" href="/docs/learn/workflows/run-workflows">
    Start runs from the CLI, triggers, the API, and agent tools.
  </Card>

  <Card title="Test workflows" href="/docs/learn/workflows/test-workflows">
    Run workflows in tests and assert their output.
  </Card>

  <Card title="Workflow runs" href="/docs/learn/logs/workflow-runs">
    Inspect input, output, steps, errors, and traces.
  </Card>
</CardGroup>
