Skip to main content
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.
1

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.
src/workflows/greeting.ts
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.
2

Deploy it

Ship the project to the platform so the workflow runs in the cloud.
keystroke deploy --project <project-slug>
Deploy builds and uploads your project, and the CLI now targets it automatically. See deploy a project for project setup.
3

Run it

Invoke the workflow by its slug with a JSON input against your deployed project.
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 that runs the workflow and asserts its output. Prefer to iterate without deploying? Run a local server with keystroke dev.
4

Run it from a trigger

In production, workflows usually run from a trigger (a webhook, schedule, or poll attached to the workflow) or as an agent tool. You can also keep invoking it from the CLI or HTTP API; see run workflows.
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.
OptionRequiredWhat it does
slugYesStable key used for discovery, the POST /workflows/{slug} route, CLI commands, and history
inputYesZod schema for the payload passed into the run
outputYesZod schema for the value run returns
runYesasync (input, ctx) => output, the orchestration body
nameNoHuman-readable name shown in the platform
descriptionNoShort description shown in the platform
subscriptionNo{ mode: "subscribable" } to enable per-user fan-out. See Subscriptions.
The run function receives the validated input and a run context (ctx). Whatever it returns is validated against output.
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.

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. To run a workflow as its own tracked run instead, import it into an agent’s tools — see Run workflows as an agent tool — or call it over HTTP or from a trigger.

Action steps

The most common step is an action. Import the action and call .run() with its input. The same action works as a workflow step or an agent tool.
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 with .prompt(). The agent prompt is part of the workflow execution and also creates its own agent session you can inspect.
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.
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 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 (see 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.

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.
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 ctxenrichLead.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.
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, 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:
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:
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:
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 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. 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:
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.
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.
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 for the HTTP call. While suspended, the run shows Waiting on hook in run history.
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 cannot suspend.

Run context

run(input, ctx) receives a context object with run metadata and the durable wait primitives:
FieldWhat it is
runIdThe unique ID of this workflow run
triggerHow the run started: api, cron, webhook, poll, or retry
triggeredAtWhen the run was triggered
sleepDurable sleep for a duration or until a Date
hookDurable hook that suspends until resumed
workspacesRootHost 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 resolve to the right account.
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

Best practices

Keep workflows replayable at runtime and legible on the canvas.

Run workflows

Start runs from the CLI, triggers, the API, and agent tools.

Test workflows

Run workflows in tests and assert their output.

Workflow runs

Inspect input, output, steps, errors, and traces.