> ## Documentation Index
> Fetch the complete documentation index at: https://app.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`, 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",
      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 key 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](/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](/learn/workflows/test-workflows) that runs the workflow and asserts its output. Prefer to iterate without deploying? Run a local server with `keystroke dev`.
  </Step>

  <Step title="Run it from a trigger">
    In production, workflows usually run from a [trigger](/learn/triggers/overview) (a webhook, schedule, or poll attached to the workflow) or as an [agent tool](/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](/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 four core fields are all you need to start; the rest are optional.

| Option         | Required | What it does                                                                                 |
| -------------- | -------- | -------------------------------------------------------------------------------------------- |
| `slug`         | Yes      | Stable key used for discovery, the `POST /workflows/{slug}` route, CLI commands, and history |
| `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                                       |
| `name`         | No       | Human-readable name shown in the platform                                                    |
| `description`  | No       | Short description shown in the platform                                                      |
| `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](/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). To run a workflow as its *own* tracked run instead, import it into an agent's `tools` — see [Run workflows as an agent tool](/learn/workflows/run-workflows#run-workflows-as-an-agent-tool) — or call it over HTTP or from a [trigger](/learn/triggers/overview).

### Action steps

The most common step is an [action](/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",
  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](/learn/agents/overview) with `.prompt()`. The agent prompt is part of the workflow execution and also creates its own agent session you can inspect.

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

export default defineWorkflow({
  slug: "summarize-inbox",
  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",
  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](/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](/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",
  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 runs **inline** in the same run: its inner steps and durable waits (`ctx.sleep` / `ctx.hook`) stay durable, and it does not create a separate tracked run. Never pass `ctx` — `enrichLead.run(input, ctx)` is not the pattern. When you want the workflow to be its own independently tracked run instead, import it into an agent's `tools` or call it over HTTP.

## 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](/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](/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](/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",
  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](/learn/workflows/run-workflows#resume-a-suspended-run) for the HTTP call. While suspended, the run shows **Waiting on hook** in [run history](/learn/logs/overview).

<Note>
  `ctx.sleep()` and `ctx.hook()` require queued execution. They work when a workflow runs from a trigger, the CLI, or the HTTP API, but a workflow used inline as an [agent tool](/learn/agents/build-agents#workflows-as-tools) cannot suspend.
</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, and each run carries that subscriber's identity so user-scoped [credentials](/learn/credentials/use-credentials#pin-a-scope) resolve to the right account.

```ts theme={null}
export default defineWorkflow({
  slug: "daily-digest",
  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="/learn/workflows/authoring-best-practices">
    Keep workflows replayable at runtime and legible on the canvas.
  </Card>

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

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

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