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.Define the workflow
A workflow needs a The
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
slug is the stable key Keystroke uses for discovery, the POST /workflows/{slug} route, CLI commands, and run history.Deploy it
Ship the project to the platform so the workflow runs in the cloud.Deploy builds and uploads your project, and the CLI now targets it automatically. See deploy a project for project setup.
Run it
Invoke the workflow by its slug with a JSON input against your deployed project.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.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.
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. |
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
Insiderun, 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.
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.
LLM steps
For a one-shot model call without defining a full agent, usepromptLlm(). Pass an outputSchema to get a validated, structured result instead of raw text.
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 andawait 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.
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
Therun 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.
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:
Parallel steps
Independent steps run concurrently withPromise.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:
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:
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 writerun:
- Put side effects inside steps. Work done directly in the
runbody (not inside an action, agent, orpromptLlmcall) 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.
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:
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.
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.
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:
| 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 for a duration or until a Date |
hook | Durable hook 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. Settingsubscription: { 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.
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.