---
url: 'https://adk.nht.io/the-loop/pipelines.md'
description: >-
  The spine of the runtime — four pipelines, two scopes, and the rule that the
  runner has no behavior of its own.
---

# Pipelines

## LLM summary — Pipelines

* **The runner has no behavior of its own** beyond walking pipelines and invoking the configured executor. Middleware is every behavior that is not the model call: retrieval, memory loading, context packing, policy enforcement, output filtering, telemetry. The [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) dispatches and emits. Middleware does the work. Without it the executor still runs, but with nothing populated in `turnMessages` / `turnMemories` / `turnRetrievables` it answers from an empty context.
* **Middleware here is a function, not a callback.** Signature is `(ctx, next) => Promise<void>`. Work before `await next()` runs as a pre-step; work after runs as a post-step; skipping `next()` short-circuits the rest of the pipeline. One function body, both legs of the trip. *Not* a lifecycle hook, *not* an `onMessage`-style handler object, *not* a guardrail bolted on top.
* **A forgotten `await next()` is reported as [`E_PIPELINE_SHORT_CIRCUITED`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_PIPELINE_SHORT_CIRCUITED).** The runner installs a `finalHandler` on every pipeline; if it never fires and the turn was not aborted, the runner emits the short-circuit error on the observability bus. Upstream post-steps still run either way. The right channel for an *intentional* refusal is `ctx.abort(reason)` — that emits no `error`; if abort lands during dispatch, `dispatchEnd.status === 'aborted'`, otherwise dispatch was never reached and `dispatchEnd` does not fire at all. Twin failure modes that are *not* detected: a second `next()` is a silent no-op; an unawaited `next()` races the downstream pipeline.
* **Throws do not unwind upstream post-steps.** The harness catches at every level of the recursion. When downstream throws, the awaiting `next()` resolves as if it had succeeded — upstream post-steps run as on the happy path. The throw surfaces only on the observability bus, as the matching pipeline-error code. A post-step that needs to know which path it is on must read the context, not infer from `next()`.
* **Four pipelines, two scopes.** Nesting is fixed: a turn contains exactly one dispatch; a dispatch contains N iterations of the executor. There is no third level. Turn-scoped pipelines: `turnInputPipeline[]` (once before the dispatch) and `turnOutputPipeline[]` (once after, **only when dispatch acked**; skipped on dispatch failure or turn abort). Dispatch-scoped pipelines: `dispatchInputPipeline[]` (once per iteration, before the executor) and `dispatchOutputPipeline[]` (once per iteration, after the executor — runs after the executor's persistence calls have already mutated the context). Turn-scoped pipelines see a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext); dispatch-scoped pipelines see an [`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext).
* **Pipelines are sequential, strict array order.** No DAG, no priority, no implicit dependency resolution. If B depends on A populating `ctx.stash`, A comes before B in the array.
* **Errors bubble through the observability bus, not through `throw`.** A thrown exception in a middleware is wrapped and emitted as `error`: `turnInputPipeline` → `E_INPUT_PIPELINE_ERROR`; `turnOutputPipeline` → `E_OUTPUT_PIPELINE_ERROR`; both `dispatchInputPipeline` and `dispatchOutputPipeline` → `E_DISPATCH_PIPELINE_ERROR` (one code for both — the runner does not split input vs. output at this layer). `run()` resolves. The consumer's observability listener is where errors are caught.
* **Cross-middleware state lives in `ctx.stash`** — a [`Registry`](https://adk.nht.io/api/@nhtio/adk/common/classes/Registry), not a typed slot. Use `stash.set('namespace.key', value)` / `stash.get<T>(...)`. Namespace your keys — collisions are silent.
* **Suspension lives here too.** A middleware (or a tool handler) calls `await ctx.waitFor(rawGate)` to open a [`TurnGate`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate). Where you open it decides what blocks: before `next()` blocks every downstream middleware; after `next()` blocks only the post-step. Approval gates that must run before the prompt is packed open *before* `next()`; human review of model output opens *after* `next()` in `turnOutputPipeline`.
* **Abort is not an error — but it is not silent.** An explicit abort (turn `AbortController` fires, or a stage throws something classified as `AbortError` via [`isInstanceOf`](https://adk.nht.io/api/@nhtio/adk/guards/functions/isInstanceOf) — a `constructor.name` match, not an `error.name` read) short-circuits the pipeline without emitting `error`. `turnEnd` always fires. `dispatchEnd` only fires if dispatch had already started — abort during `turnInputPipeline` skips dispatch entirely. When dispatch did start, `dispatchEnd.status === 'aborted'` is the operational signal; when it did not, classify on `turnEnd` plus the absence of `dispatchStart`. Alerting on `error` only will miss abort traffic in either case.
* **Common mistake:** capturing state across iterations via closure in a dispatch pipeline (`dispatchInputPipeline` or `dispatchOutputPipeline`). The middleware is invoked fresh each iteration; use `ctx.stash` or look up `ctx.iteration` to scope state correctly.
* **Common mistake:** treating turn and dispatch `stash` as the same registry. They are not. Turn `stash` is fresh per turn; dispatch `stash` is fresh per dispatch. Mutations do not bubble.

The ADK is built on four pipelines. They are the load-bearing structure of every turn — the runner walks them in order, and that walking *is* the turn. Each pipeline opens onto one of two context objects: a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext) for the turn, a [`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext) for one trip through the executor. Those contexts are what the model ultimately sees.

Middleware is how you write to them. Nothing reaches the model that you did not put in a middleware's hands. Pipelines without middleware run, finish, and produce nothing. Middleware without pipelines is a pile of functions with nowhere to attach.

## The four pipelines

::: danger The runner has no behavior of its own
Everything the ADK actually *does* around a turn — load history, retrieve documents, score memories, pack the prompt, enforce policy, apply post-hoc safety, emit telemetry — lives in middleware you compose. The [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) is the loom. Middleware is the thread. The configured executor still runs, but with no middleware to populate `turnMessages` / `turnMemories` / `turnRetrievables` ahead of it, the model sees an empty context and answers from nothing. There is no helpful default waiting in the wings.
:::

Four moments in a turn where the runner stops and runs whatever middleware you registered: before the model is dispatched, after it has finished, and — because the dispatch is a loop — before and after each iteration of the executor.

| Pipeline | Scope | Runs | Type |
| --- | --- | --- | --- |
| `turnInputPipeline` | Turn | Once before dispatch | [`TurnPipelineMiddlewareFn`](https://adk.nht.io/api/@nhtio/adk/turn_runner/type-aliases/TurnPipelineMiddlewareFn) |
| `turnOutputPipeline` | Turn | Once after dispatch, only when dispatch acked (skipped on dispatch failure or turn abort) | [`TurnPipelineMiddlewareFn`](https://adk.nht.io/api/@nhtio/adk/turn_runner/type-aliases/TurnPipelineMiddlewareFn) |
| `dispatchInputPipeline` | Dispatch | Once per iteration, before the executor | [`DispatchPipelineMiddlewareFn`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/type-aliases/DispatchPipelineMiddlewareFn) |
| `dispatchOutputPipeline` | Dispatch | Once per iteration, after the executor | [`DispatchPipelineMiddlewareFn`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/type-aliases/DispatchPipelineMiddlewareFn) |

The nesting is fixed: a turn contains exactly one dispatch, and a dispatch contains N iterations of the executor. There is no third level. The split into two scopes is deliberate: turn-level cost should not be paid on every iteration, and a dispatch-scoped helper has no business leaking into the next turn. Turn-scoped pipelines see the [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext); dispatch-scoped pipelines see the [`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext), which adds the executor-iteration primitives ([`DispatchContext.iteration`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-iteration), [`DispatchContext.ack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#ack), [`DispatchContext.nack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#nack), [`DispatchContext.onAck`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#onack), [`DispatchContext.toolCallCount`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#toolcallcount)).

## What a middleware is

A middleware is a function. The runner hands it the context and a `next` continuation, and gets out of the way. What the function body does on either side of `next` is the work — `next` itself is only the hand-off to the rest of the pipeline.

That is the whole shape. Not a callback the runner fires at a named moment. Not a lifecycle hook. Not a handler object with `before` and `after` methods. Not a guardrail bolted onto something else. A function that owns its slot in the pipeline, decides what to do with the context, and decides whether the pipeline continues. The runner does not choose your behavior, but it is strict about the shape: one continuation, ordered execution, explicit aborts, reported short-circuits.

## In other words

The same shape goes by *hooks*, *callbacks*, *interceptors*, *filters*, *wrappers*, *guardrails* — user code interposing on someone else's control flow. ADK middleware is that idea with the loose parts nailed down. Hooks and callbacks fire at named moments; an ADK middleware owns a slot in a sequence the runner walks. Interceptors and filters wrap a single target; an ADK middleware composes across the whole pre/post journey on a shared context. Wrappers and guardrails sit at the edges; ADK middleware sits inside the four pipelines that *are* the agent.

## Why pipelines, and not named callbacks

A turn is not a sequence of moments to react to. It is a sequence of transformations on one shared context, and every transformation has two sides — what it does on the way in, and what it has to do on the way out. Retrieval acquires a connection then releases it. Memory loading scores records then writes back which ones were used. A budget takes a lease then releases it — even if the work in the middle threw. Two halves of the same job, in the same function body.

A callback at a named moment sees half the trip. The other half belongs to whatever the framework fires next, on the framework's terms. A pipeline has no named moments. Each middleware owns its slot, owns both halves, and the framework stays uninvolved.

The pipeline runner is `@nhtio/middleware` for two reasons. It runs anywhere JavaScript runs — no `node:fs`, no environment-bound primitive in the contract. And it does nothing else: it runs pipelines, knows nothing of agents or LLMs or HTTP. The semantics that matter live in this library, not in the executor.

## Read the seam before you wire the seam

* [What each pipeline owns](./pipelines/what-each-pipeline-owns) — what conventionally goes in each of the four pipelines.
* [Composition](./pipelines/composition) — `next()`, sequencing, where to open a gate.
* [Throws](./pipelines/throws) — how throws are wrapped, why post-steps run on the error path, and the commit-vs-rollback pattern.
* [Abort](./pipelines/abort) — `ctx.abort(reason)`, `ctx.abortSignal`, and classifying abort traffic from outside the runner.
* [`stash`](./pipelines/stash) — cross-middleware state and isolation between turn and dispatch scopes.
* [Turn Runner](./turn-runner), [LLM Dispatch](./llm-dispatch), [Gates](./gates), [Events](./events).
