Skip to main content
Inside a workflow, every call to an action is a durable step. You import the action and call .run() with its input; the workflow records the result so a retry can skip work that already succeeded. This is the most common way to compose actions, since an action can never call another action directly.

Call an action as a step

Import the action and await its .run() inside the workflow’s run function. The input is validated against the action’s input schema, and the result is validated against its output.
src/workflows/signup-pipeline.ts
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 };
  },
});
Each .run() is a recorded step. Steps run in the order you await them, and you can branch, loop, and pass one step’s output into the next like any other async/await code.

Integration actions as steps

Integration actions are used directly as steps; never wrap them in a custom action. Import only the ones a workflow needs.
import { slackSendMessage } from "@keystrokehq/slack/actions";

async run(input) {
  await slackSendMessage.run({ channel: input.channel, markdown_text: input.summary });
  return { sent: true };
}

Durability

As each step completes, its result is written to the run’s 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 hasn’t finished. Two practical consequences:
  • Put side effects inside steps. Work done directly in the run body (not inside an action, agent, or LLM call) runs again on every replay. Keep network calls and writes inside .run() so they’re recorded and not repeated.
  • Make steps idempotent where you can. A step can be retried after a transient failure, so design actions to tolerate being called more than once with the same input.
See durability and retries for the full model.

Scope credentials per step

When a step’s action declares credentials that could resolve at more than one scope, pin the scope with .scope():
await slackSendMessage.run({ channel, markdown_text }).scope("user");
Without an explicit scope, the resolver uses the project default, then the organization default.

Actions, agents, and LLM steps

A workflow can mix deterministic action steps with agent steps (agent.prompt()) and one-shot LLM steps (promptLlm()). 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.

Next steps

Build workflows

Steps, durability, durable waits, and the run context.

Actions as agent tools

Attach the same actions to an agent as tools.

Test workflows

Run a workflow and assert its output.

Workflow runs

Inspect each step’s input, output, and errors.