Stagehand

Phases and Steps

How addPhase and addStep build a script, and what phase boundaries mean for rollback scope and cache slots.

A script is built by chaining addPhase and addStep calls on a Script instance. Each call returns a (re-typed) Script, so the chain reads as the script’s own outline.

Adding a phase

new Script<Input>({ name: "deploy" })
  .addPhase("Validation")
  .addStep({ /* ... */ })
  .addStep({ /* ... */ })
  .addPhase("Release")
  .addStep({ /* ... */ });

addPhase opens a new phase; every addStep call after it lands in that phase until the next addPhase. A phase needs a name, which must be unique across the whole script: two phases (or two steps within one phase) sharing a name throws DuplicateNameError when the script is built, not at run time. Names are not cosmetic: they label the frame, they are what outline() reports, and, once a phase caches, they identify its cache entry.

If you never call addPhase, addStep creates an implicit phase named "Main" the first time it is called.

Phase options

.addPhase("Release", {
  description: "Ship the built artifact",
  when: ({ input, ctx }) => input.environment === "production",
  cache: { store, stale: ({ ctx }) => ctx.sha !== lastBuiltSha },
})

when skips every step in the phase (see below). cache is covered in Caching.

The callback form

addPhase also accepts a callback, which groups a phase’s steps visually while keeping the same type flow as the flat form:

new Script<{ n: number }>({ name: "t" })
  .addPhase("A", (script) =>
    script
      .addStep({ name: "double", handler: ({ input }) => ({ doubled: input.n * 2 }) })
      .addStep({ name: "label", handler: ({ ctx }) => ({ label: `n=${ctx.doubled}` }) }),
  )
  .addPhase("B")
  .addStep({ name: "use", handler: ({ ctx }) => ({ echo: ctx.label }) });

The two forms are interchangeable. The callback exists purely for readability when a phase has several steps and you want its boundary visible in the source.

Adding a step

.addStep({
  name: "resolve commit",
  description: "look up the HEAD sha for this ref",
  handler: async ({ input, ctx, status }) => {
    status("querying git");
    return { sha: await git.head(input.service) };
  },
})

name must be unique within its phase (the same name in two different phases is fine, since a step’s cache slot is "phase::step", so nothing collides). handler is the only required field; everything else (rollback, rollbackKeys, clean, when, cache, retry, timeoutMs, description) is optional. Each of those has its own guide: Rollbacks for rollback/rollbackKeys, Cleaning Context Keys for clean, Caching for cache, and Script Options and Results for retry and timeoutMs.

when: conditional steps and phases

Both addStep and addPhase accept when, a function of { input, ctx } that may be async:

.addStep({
  name: "notify slack",
  when: ({ input }) => input.environment === "production",
  handler: async () => { /* ... */ },
})

A step whose when resolves falsy is marked skipped — its rollback, if it has one, never runs, because the step never did anything to compensate for. Phase-level when skips every step in the phase the same way. clean still applies to a skipped step: it is part of the declared shape of the context, not of the work done, so the type and the runtime state never disagree about what a skipped step removed. See Cleaning Context Keys for why that matters.

addBranch: one step, two ways to do it

when can only skip a step, which is the wrong tool when later steps need what that step produces: the skipped step leaves nothing behind, but ctx is typed as if it had run. A branch picks between two arms that return the same shape, so ctx is true whichever one ran. An override flag is the typical case: use the supplied value, or work it out.

new Script<{ service: string }>({ name: "deploy" })
  .defineFlag({ name: "sha" })
  .addPhase("Resolve")
  .addBranch({
    name: "resolve commit",
    condition: ({ flags }) => Boolean(flags.sha),
    onTrue: ({ flags }) => ({ sha: flags.sha as string }),
    onFalse: async ({ input }) => ({ sha: await git.head(input.service) }),
  })
  .addStep({ name: "build", handler: ({ ctx }) => ({ image: `app:${ctx.sha}` }) });

condition, onTrue and onFalse all receive the full handler context (input, ctx, flags, status, prompt, …), and condition may be async. It runs once per attempt; only the chosen arm executes.

onTrue’s return type fixes the step’s output, and onFalse has to return something assignable to it. Return different shapes and it is a compile error on onFalse, rather than a union that every later step has to narrow.

A branch is deliberately small. It takes name, description, retry and timeoutMs, which cover the whole step (condition and arm together), and nothing else: no rollback, cache, clean or when. To cache the result, cache the phase the branch sits in; to drop a key, clean it in a later step.

outline()

Every Script exposes outline(), which reports the declared structure without running anything, handy for tests and for generating documentation of a script from itself:

script.outline();
// [
//   { phase: "Validation", steps: ["resolve commit", "check permissions"] },
//   { phase: "Build", steps: ["install dependencies", "compile bundle"] },
// ]

Why phase boundaries matter beyond grouping

Two behaviors key off which phase a step belongs to, not just its position in the overall sequence:

  • Rollback scope. rollback: "phase" (a ScriptOptions setting) unwinds only the steps in the phase that failed, not the whole script. See Rollbacks.
  • Cache slots. A cached phase stores its steps’ combined delta under a slot named after the phase. A cached step stores under "phase::step". Both are addressable from context.cache inside any step declared afterward. See Caching.

So a phase is not just an outline label — it is the unit both of compensation scope and of cache identity, which is why splitting work into phases is worth doing deliberately rather than defaulting everything into "Main".