durable: true:
Everything in the SDK is already a step
You don’t need to change anything to make the built-in SDK calls durable.toolbox.*, generateText(), runAndWait(), and state.get / state.set are already durable steps. Each one runs once, its result is journaled, and it never runs again on resume.
Making your own calls durable with step()
Anything that is not a Terse SDK call becomes a durable step by wrapping it instep(). That covers third-party SDKs like Octokit or Resend, a raw fetch, a database write, anything with a side effect.
- The thing you call lives at module scope (or is imported): a client like
resend, or one of your own functions. Steps run in a separate bundle that rebuilds module scope from your source, so module-level values are always available there. A client created inside the handler is not, and the build will tell you to move it. - The arguments are data. They evaluate right where you wrote them, with full access to
eventand your locals, and the resulting values are serialized into the journal. A function value cannot cross that boundary, so callbacks in argument position are a build error (see below).
SDKs that return errors as values instead of throwing (Resend, Supabase) need their errors re-raised:
if (error) throw .... A run only fails when the handler throws, so an unchecked error value gets journaled as a successful step. Relatedly, if such an SDK returns a generic network error that makes no sense (Resend: “Unable to fetch data. The request could not be resolved.”), check that the call is wrapped in step(). Workflow code runs in a sandbox without network access, and these SDKs catch the sandbox’s descriptive “fetch is unavailable” error and rewrap it as their own.jobStep: the fully explicit form
jobStep does the same thing with no rules about the call shape: you declare the input, and run receives it. Because the input object is the entire boundary contract, the body can be anything, including things step() rejects: construct the client inside the step, use callbacks inline, write as many statements as you want. step() is the cleaner spelling for the common case; jobStep always works.
inputis the data the step needs. It is the only thing passed in, because steps run in isolation and cannot read variables from the surrounding handler.inputSchemaandoutputSchemaare zod schemas that validate the values crossing the durability boundary on every run, which.asStep()does not do.outputSchemais optional.runreceives the validated input and returns the result, which is journaled.
run:
Logging
Because the handler replays from the top, a bareconsole.log outside a step prints once per replay: a summary logged before three steps shows up four times in terse test output. That repetition is real execution behavior and can be a useful window into how replay works, but it makes output hard to read.
Use log() for lines that should print exactly once. It is a journaled step, so it prints when first executed and is skipped on every replay after that:
console.log is already fine: the body runs once, so its logs print once. Unlike step(), log() is not compiled per-file, so it works from helper files too, and in non-durable jobs it simply forwards to console.log.
Waiting
Durable jobs can sleep for as long as you want, from minutes to days, without holding a process open. The job suspends and resumes when the timer fires. Nothing runs and nothing is billed while it waits.ms-style strings: "30s", "5m", "1h", "3d".
During local testing with terse test (no TERSE_RUN_ID), sleep() skips the wait and logs what production would have done instead.
Waiting for human input
UsewaitForInput() when a durable job needs a human decision before it can continue. You define the prompt, optional detail fields, and a list of action buttons. In production, Terse posts an interactive Slack message and suspends the run until someone responds. The response is journaled like any other step, so the job never asks twice on replay.
id (returned as result.choice), a label shown on the button, and an optional description. Set freeText: true on an option to collect a follow-up text field after the user selects it; the value is available on result.text.
result.respondent identifies who answered (provider, userId, and optional displayName). result.delivery includes provider-specific metadata (for Slack, the channel and message timestamp).
During local testing with terse test, waitForInput() posts the same Slack message it would in production, marked as coming from terse test. The run does not suspend locally; it keeps running and returns as soon as someone answers in Slack. The wait runs as a journaled step, so durable replays return the answer from the journal instead of asking again.
sleep(), waitForInput(), and jobStep() are only available in durable jobs. Call them in a non-durable job and you get a clear error asking you to add durable: true.Structuring durable handlers
Two habits keep a durable handler readable as it grows. Write the happy path as sequential step blocks. Keepstep() calls inline in the handler, one after another, so the top of the job reads like a list of the things it does.
Give branches their own functions. When a conditional path does real work — steps, tool calls, waits — extract it into a named helper below the job, with its own steps inside. The handler stays a top-down summary, and you open a branch only when you care about it. Keep helpers in the same file as createJob(): step() is compiled per-file, so a helper moved elsewhere won’t have its steps transformed.
