Stagehand

Reusable Scripts and Mounts

routineFor declares a reusable fragment of phases; use() mounts it, a whole plain Script, or the same routine twice with its own input.

routineFor does for whole phases what stepFor does for one step: it lets a group of phases be declared once, away from any particular script, and mounted wherever it is needed.

// routines/channel.ts
import { fileStore, routineFor } from "@michaelrwalker/stagehand";

const cache = fileStore("./.stagehand-cache.json");

export const pullChannel = routineFor<{ channel: string; since: string; apiKey: string }>()(
  "pull channel",
  (script) =>
    script
      .addPhase("Fetch", { cache: { store: cache, stale: ({ ageMs }) => ageMs > 3_600_000 } })
      .addStep({ name: "authenticate", handler: async ({ input }) => ({ token: input.apiKey }) })
      .addStep({ name: "pull orders", handler: async ({ input }) => ({ orders: await pull(input.channel) }) })
      .addPhase("Normalize")
      .addStep({ name: "dedupe and total", handler: ({ ctx }) => ({
        orderCount: ctx.orders.length,
        gross: ctx.orders.reduce((sum, o) => sum + o.total, 0),
      }) }),
);
new Script<Input>({ name: "channel sync" })
  .use(pullChannel)
  .addPhase("Validation")
  .addStep({ name: "check totals", handler: ({ ctx }) => /* ctx.orderCount, ctx.gross */ ({}) })

// months later, a different job entirely
new Script<ReportInput>({ name: "monthly report" })
  .use(pullChannel)
  .addPhase("Report")
  .addStep({ name: "build summary", handler: ({ ctx }) => /* ... */ ({}) })

The two type parameters on routineFor<In, Ctx>() are the minimum the fragment needs — the input it reads and the context it expects to already exist. Both are checked at the use() call site, not inside the routine’s own definition:

Property 'hash' is missing in type '{}' but required in type '{ hash: string; }'.

Mounting

new Script<Input>({ name: "channel sync" }).use(pullChannel)

use() splices the routine’s phases into the host script, in order. Everything the routine produces is merged into the context, so steps that follow use() see it, typed, exactly as if they had been written inline. A Routine’s own phase- and step-level options travel with it in full: phase-level when and cache, step-level retry, clean, rollback. A spliced rollback compensates during the host’s unwind, and any keys it reserved through rollbackKeys stay reserved in the host, so a later clean there still cannot remove them.

A plain Script mounts too — not just something built with routineFor — which is what keeps a pipeline runnable on its own:

const pipeline = new Script<{ channel: string }>({ name: "pull" })
  .addPhase("Fetch")
  .addStep({ name: "pull", handler: ({ input }) => ({ rows: [input.channel] }) });

await pipeline.run({ channel: "etsy" }); // runs standalone today

new Script<{ channel: string }>({ name: "report" })
  .use(pipeline) // mounts into something bigger tomorrow
  .addPhase("Report")
  .addStep({ name: "count", handler: ({ ctx }) => ({ n: ctx.rows.length }) });

A mounted Script’s own ScriptOptions (its rollback mode, logPlacement, silent) are ignored in favor of the host’s. Only its phases and steps come across; the host decides how the whole run is governed.

Cache slots travel with the mount

Mounting a routine whose phase caches means the second script to mount it gets the data the first one already pulled, without re-declaring the cache at all:

› Fetch
  ⊙ authenticate  cached
  ⊙ pull orders  cached
› Report
  ✔ build summary (avg 24.50)  263ms

That works because the cache store lives inside the routine’s own module (const cache = fileStore(...) above, module-scoped): every script that imports and mounts the routine shares the same store, and the slot is derived from the phase name, which is identical across mounts unless renamed by as.

Giving a mount its own input

A mount can feed its fragment something other than the host’s own input, which is what lets one routine serve two storefronts, each with its own credentials, inside a single script:

new Script<{ since: string; amazonKey: string; shopifyKey: string }>({ name: "all channels" })
  .use(pullChannel, {
    as: "Amazon",
    input: ({ input }) => ({ channel: "amazon", since: input.since, apiKey: input.amazonKey }),
  })
  .use(pullChannel, {
    as: "Shopify",
    input: ({ input }) => ({ channel: "shopify", since: input.since, apiKey: input.shopifyKey }),
  })

input takes a fixed value or a function of { input, ctx }, and it may be async. It is resolved once per mount, at the moment the mount is reached in execution order, and every phase and step of that fragment sees the result, including its when, its cache, and its rollbacks during an unwind (a mounted step’s rollback gets the mount’s input, not the host’s own).

With a mapper, the host no longer has to match the routine’s input shape at all — it only has to produce it. TypeScript checks the return value against the routine’s declared In:

Property 'apiKey' is missing in type '{ channel: string; }' but required in type 'ChannelInput'.

Without a mapper, the host’s own input must satisfy the routine’s requirement directly, the same way stepFor steps require of a script that mounts them.

Mounting twice

Mounting the same routine more than once requires as, because phase names have to stay unique within a script:

.use(pullChannel, { as: "Amazon" })     // → "Amazon / Fetch", "Amazon / Normalize"
.use(pullChannel, { as: "Shopify" })    // → "Shopify / Fetch", "Shopify / Normalize"

Without as, mounting twice throws DuplicateNameError at build time: the same rule two identically-named phases anywhere in a script are refused under, routine or not. Names label the frame, are what outline() reports, and identify a cache entry; two units sharing a name would share a slot, which surfaces as wrong data rather than as a failure, so it is refused up front instead.

as also renames the mounted fragment’s cache slots, not just its phases, so the moment you add { as: "Amazon" }, every cache.clear("Fetch") written against the original phase name goes red at compile time and offers "Amazon / Fetch" in its place. See Caching for how that interacts with context.cache.

addStep after use() is refused

A routine owns whole phases, so appending a bare step directly after use() is a build-time error. Open a phase of your own first, rather than quietly appending to a fragment some other script also mounts:

new Script({ name: "t" })
  .use(pullChannel)
  .addStep({ name: "sneaky", handler: () => ({}) });
  // StepDefinitionError: Cannot add a step after use(): "Normalize" belongs
  // to a mounted fragment. Open a phase of your own with addPhase() first.