Stagehand
Cleaning Context Keys
How clean drops context keys at runtime and from the type, and the rules that keep it consistent with rollbackKeys.
A handler’s return value stays in the context for the rest of the script by
default. When a step produces or receives data that later steps do not need,
listing those keys in clean deletes them from the context at runtime and
removes them from the type every later step sees:
new Script<{ password: string; email: string }>({ name: "signup" })
.addStep({
name: "read input",
handler: ({ input }) => ({ ...input }),
})
.addStep({
name: "hash",
handler: async ({ ctx }) => ({ hash: await hashIt(ctx.password) }),
clean: ["password"], // not needed past this point
})
.addStep({
name: "persist",
handler: ({ ctx }) => {
ctx.hash; // string
ctx.email; // string
ctx.password; // compile error — cleaned away
},
});
The rules
-
Keys are checked against the context as it stands entering that step. An unknown key is a compile error, and the editor autocompletes only the valid ones. A step cannot clean a key it produces itself — that key does not exist yet at the point
cleanis evaluated, since a step’s own output and itscleanlist are resolved from the same incoming context..addStep({ name: "s", handler: ({ ctx }) => ({ temp: ctx.a * 10 }), clean: [ // @ts-expect-error - "temp" is this step's output, not its input "temp", ], }) -
A key reserved by any step’s
rollbackKeyscan never be cleaned, by that step or any later one. Trying to throwsStepDefinitionErrorfromaddStep, at script build time, before anything runs. See Rollbacks for why the reservation exists. -
cleanis part of the declared shape, not of the work, so it applies even when the step is skipped bywhen: runtime and types never disagree about what a skipped step removed:new Script<{ secret: string }>({ name: "t" }) .addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) }) .addStep({ name: "optional", when: () => false, handler: () => ({ used: true }), clean: ["secret"], }) .run({ secret: "hush" }); // result.ctx is {} — "secret" is gone even though "optional" never ranThe same is true for a step served from cache:
cleanstill applies on a hit, since it describes the shape the context has after the step settles, regardless of how it settled. -
A step’s own
outputis kept separately and is never cleaned, so itsrollbackstill sees everything the handler returned, even ifcleanremoved those same keys from the live context:.addStep({ name: "one", handler: () => ({ token: "tok_1", scratch: "temp" }), rollbackKeys: ["a"], rollback: ({ ctx, output }) => { // output is still { token: "tok_1", scratch: "temp" } in full }, }) .addStep({ name: "drop it", handler: () => ({}), clean: ["scratch"] })
Why clean exists as its own field
An alternative design would let a handler just… not return a key it wants
gone, or delete it from ctx directly. Neither works here: a handler’s return
value is what defines the type widening for addStep, so the type has to be
declared statically rather than computed from what a function chooses to omit at
runtime, and ctx inside a handler is read-only by contract even though nothing
stops a stray mutation at the JS level. clean is a separate, declarative field
precisely so the compiler can check it against the context as it enters the
step — the same mechanism that lets it also enforce the rollbackKeys
reservation rule above.
Interaction with caching
A cached phase stores its delta: the keys its steps contributed, minus
anything they cleaned. Because clean runs before the delta is committed, a
cache entry never contains a key that would have been unreachable from the
context anyway; a later run’s cache hit reproduces the same shape the phase
would have left behind if it had actually executed. See
Caching for the full mechanics of what gets stored.