Skip to content
6 min read · 1,173 words

stash

stash is the sanctioned scratchpad. Use it when middleware needs to pass state sideways without inventing a private global, closure leak, or fake primitive. One middleware retrieves documents and counts chunks; the next reads the count to drive a budget decision. That state lives on ctx.stash — a Registry that exists for the lifetime of one context.

Pipelines is the hub. Composition covers ordering and sequencing.

The registry, deliberately unschemed

Five methods: get, set, has, keys, all. Keys are strings; values are unknown; the runner does not type-check.

ts
ctx.stash.set('my-org.retrieval-count', chunks.length)
const count = ctx.stash.get<number>('my-org.retrieval-count')
if (ctx.stash.has('my-org.policy-override')) { /* ... */ }

Dots in keys create real nested objects: set('my-org.count', 5) stores { "my-org": { "count": 5 } }, not a flat string key. This means all() returns a nested object tree, while keys() returns an array of leaf dot-paths (e.g. ['my-org.count']). Object.keys(ctx.stash.all()) only returns top-level segments.

has treats a stored undefined as absent — the same convention as get's defaultValue fallback, so has(key) and get(key) !== undefined always agree. get and all deep-clone, so what you read out is never a live reference into the store — mutating a returned value cannot mutate the registry. set, by contrast, stores the value you handed it by reference: if you keep a reference to that object and mutate it later, you mutate what is in the registry. If that matters, clone before you set.

A typed slot would force every middleware author to negotiate a shared type with every other author. An unschemed registry lets any of them write and any read, at the cost of one type parameter per consumer. That trade is the reason for the shape.

The discipline is yours. If a producer changes the shape and a consumer is not updated, the runtime will not catch the drift. Treat stash like a side-channel API with no compiler. If you change the shape, update every reader, or the bug will surface three middleware later wearing somebody else's stack trace.

Namespacing

Collisions are silent and structural

The runner does not warn when two writers reach for the same key. Second writer wins. Because dots create real nested structure, this includes structural collisions: set('my-org', value) after set('my-org.count', 5) silently erases the count sub-key. Namespace every key and avoid using a parent path as a value slot if it has children.

Convention is '<namespace>.<key>':

ts
ctx.stash.set('my-org.policy', policy)              // good
ctx.stash.set('adk-battery-openai.retry-count', 2)  // good
ctx.stash.set('data', payload)                       // landmine
ctx.stash.set('count', 5)                            // landmine

Namespace segments must not contain literal dots. Version strings (v1.2), model names (gpt-4.1), domain-style names (com.example), or user IDs with dots are dangerous because the registry interprets dots as hierarchy. Use hyphens instead: gpt-4-1.

Dot-paths are supported for nested values:

ts
ctx.stash.set('my-org.budgets.input-tokens-remaining', 4096)
const remaining = ctx.stash.get<number>('my-org.budgets.input-tokens-remaining')

Arrays and numeric paths: The failure modes

The registry provides deep access but is not defensive. Using numeric segments in a path (e.g., items.0.id) or attempting to treat arrays as objects will fail in ways the compiler cannot catch.

  • The first write locks the type. If one middleware creates an array at a path, a subsequent middleware cannot treat that path as an object. Attempts to do so will fail silently or corrupt the state.
  • Metadata on arrays vanishes. You can add named properties to an array in memory, but they are stripped during persistence. If the turn persists and reseeds, any keys added to an array (e.g., set('items.foo', 'bar')) are gone.
  • Persistence creates ghost data. Sparse arrays (arrays with holes) become dense during a JSON round-trip. A hole that was absent (has is false) becomes an explicit null (has is true) after persistence. What was missing is now there.
  • Prototypes are exposed. The registry does not hide the Array prototype. get('items.length') returns a number and get('items.map') returns a function. You are looking directly at the object's guts.
  • Null values create write-deadlocks. Setting a key to null prevents any future writes to child paths under that key. Any attempt to set('a.0', x) after set('a', null) will throw a TypeError.

The Rule: Arrays are opaque leaves. Do not use numeric segments in paths. If you need to work with an array, get the whole array, mutate it in your middleware, and set the result back.

ts
// WRONG: Numeric path segments
ctx.stash.set('my-org.items.0', item)

// RIGHT: Opaque leaf
const items = ctx.stash.get<Item[]>('my-org.items', [])
items.push(item)
ctx.stash.set('my-org.items', items)

Turn stash and dispatch stash are separate

Two registries, one-way seed

TurnContext.stash and DispatchContext.stash are not the same store. When a dispatch begins, the runner deep-clones the turn registry as the dispatch registry's initial state. After that, nothing syncs in either direction. Dispatch mutations are invisible to turnOutputPipeline; turn mutations made after dispatch starts are invisible to dispatch middleware.

The turn's registry lives for the turn — populated by turnInputPipeline, read by turnOutputPipeline, gone at turnEnd. The dispatch's registry is seeded from the turn's at dispatch entry, then lives for the dispatch — populated by dispatchInputPipeline, read by dispatchOutputPipeline, persisting across iterations, gone at dispatchEnd.

The seed direction is intentional. Turn-level inputs (policy flags, identity attributes, request metadata) are useful inside the dispatch loop; dispatch-internal scratch (iteration counters, transient retry hints) is not useful to turnOutputPipeline. If you need dispatch-scoped state to reach turnOutputPipeline, write it through a primitive collection — turnMessages / turnMemories / turnRetrievables / turnThoughts / turnToolCalls do thread through, because the runner flushes dispatch-scoped mutations back to the parent TurnContext at the end of each iteration. See LLM Dispatch.

Across iterations, within one dispatch

Dispatch stash persists across iterations within the same dispatch. A counter incremented in iteration 0's dispatchOutputPipeline is still there in iteration 1's dispatchInputPipeline.

Don't capture iteration state in closure

A middleware in dispatchInputPipeline or dispatchOutputPipeline is invoked fresh each iteration. A let counter = 0 at module scope captures dispatch-spanning state across all dispatches; a let counter = 0 inside the function body resets every iteration. Neither does what you want. Use ctx.stash for dispatch-scoped state, or DispatchContext.iteration for iteration-counting.

Across turns

Each turn gets a fresh turn registry. The runner does not carry stash across turns on its own.

  • Seed it on the next turn's input. The RawTurnContext you hand to runner.run() has an optional stash field — it populates ctx.stash before turnInputPipeline runs. You durably store it in your own persistence layer and re-seed it on the next turn.

Seed format must be nested

The nested object returned by all() is the correct format for re-seeding a turn. A flat-keyed object like { 'my-org.count': 5 } passed to RawTurnContext.stash will not be read correctly by get('my-org.count') — the registry expects the nested form { 'my-org': { 'count': 5 } }.

  • Write it through a primitive. Memory is the model-visible primitive for cross-turn state that should influence the model. Standing instructions are the developer-policy variant. If state belongs in the prompt, persist it as one of these — not as opaque stash — and the rendering battery handles the rest.

There is no storeStash callback in the runner config. No canonical schema to store — same trade-off that makes stash itself unschemed.

Where to go next