---
url: 'https://adk.nht.io/the-loop/pipelines/stash.md'
description: >-
  The cross-middleware state contract — registry pattern, namespacing rules, the
  turn/dispatch isolation contract, and cross-turn persistence.
---

# `stash`

## LLM summary — stash

* `stash` is a [`Registry`](https://adk.nht.io/api/@nhtio/adk/common/classes/Registry) on each context: turn-scoped on [`TurnContext.stash`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-stash), dispatch-scoped on [`DispatchContext.stash`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-stash).
* Surface: `stash.set('ns.key', value)`, `stash.get<T>('ns.key')`, `stash.has('ns.key')`, `stash.keys()`, `stash.all()`. Dot-paths create real nested structure (not flat keys). `all()` returns a nested object; `keys()` returns leaf dot-paths. `has` treats a stored `undefined` as absent.
* **No schema.** Deliberate. Lets any middleware — yours, a battery's, a third party's — collaborate without negotiating a shared type. The cost is one type parameter per `get` call.
* **Namespace your keys.** `stash.set('my-org.policy', …)`, not `stash.set('data', …)`. Collisions are silent and structural (e.g. `set('a', val)` overwrites `set('a.b', val)`). Namespace segments must not contain literal dots (use hyphens).
* Turn and dispatch `stash` are **separate registries** with a one-way seed: when a dispatch begins, the runner deep-clones the turn registry as the dispatch registry's initial state. After that point neither side syncs back.
* Per-turn collections (`turnMessages`, `turnMemories`, etc.) **do** thread through. Dispatch-scoped mutations queue as deltas and flush back to the parent turn at the end of each iteration.
* Cross-turn persistence: pass `stash` on the next turn's input via [`RawTurnContext.stash`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/RawTurnContext#property-stash). **Seed must be nested** (output of `all()`), not flat-keyed. What is durable across turns is whatever you persist via the runner-config callbacks and re-seed.
* Common mistake: capturing dispatch-iteration state via closure in `dispatchInputPipeline` / `dispatchOutputPipeline`. The middleware function is invoked fresh each iteration; use `ctx.stash` (persists across iterations within the dispatch) or read [`DispatchContext.iteration`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-iteration).

`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`](https://adk.nht.io/api/@nhtio/adk/common/classes/Registry) that exists for the lifetime of one context.

[Pipelines](../pipelines) is the hub. [Composition](./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

::: warning 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

::: danger Two registries, one-way seed
[`TurnContext.stash`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-stash) and [`DispatchContext.stash`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-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`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext) at the end of each iteration. See [LLM Dispatch](../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`.

::: warning 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`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-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`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/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.

::: danger 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`](https://adk.nht.io/api/@nhtio/adk/common/classes/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

* [Pipelines](../pipelines), [Composition](./composition), [What each pipeline owns](./what-each-pipeline-owns).
* [LLM Dispatch](../llm-dispatch), [Turn Runner](../turn-runner).
