Skip to main content
A durable job can pause for long waits, survive process restarts and redeploys, and never repeat work it has already done. Each step runs exactly once and its result is remembered, so an interrupted job resumes from where it left off instead of starting over. Durability is opt-in per job. Add durable: true:
Without the flag a job runs start to finish in a single execution (the default). With it, the job can suspend and resume.

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.
Every line above is a durable step. If the job resumes, completed calls replay from the journal: the model isn’t re-prompted, the Slack message isn’t sent twice.

Making your own calls durable with step()

Anything that is not a Terse SDK call becomes a durable step by wrapping it in step(). That covers third-party SDKs like Octokit or Resend, a raw fetch, a database write, anything with a side effect.
Code outside a step runs again on every replay, not just once. If it has a side effect and is not idempotent (sending an email, charging a card, creating a record), it will happen multiple times and cause real problems. Wrap every side effect in step() so it runs exactly once. This includes console.log, which repeats once per replay; use log() for output that should print once.
Two rules make a call steppable:
  • 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 event and 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).
For a step that is more than one call, write a module-scope function and wrap the call to it. This is also the pattern for callback-taking APIs: the callback stays inside the step, and only data crosses.
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.
  • input is 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.
  • inputSchema and outputSchema are zod schemas that validate the values crossing the durability boundary on every run, which .asStep() does not do. outputSchema is optional.
  • run receives the validated input and returns the result, which is journaled.
For a side effect with no input or output, just pass run:

Logging

Because the handler replays from the top, a bare console.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:
Arguments are serialized into the journal like any step input, so log data, not functions. Inside a step body, plain 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.
The model runs today, the job sleeps for three days, and the follow-up uses the exact text generated earlier. You never re-generate it. Durations are 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

Use waitForInput() 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.
Each option has an 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. Keep step() 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.
Branch conditions should derive from the trigger event or from step results, so every replay takes the same path.

How it works

A durable job’s handler is replayed from the top each time it advances. Completed steps return their recorded result instead of running again, and only new work executes. That is why side effects must live inside steps: anything outside a step runs on every replay.