Pipelines
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 for the turn, a 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
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 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 |
turnOutputPipeline | Turn | Once after dispatch, only when dispatch acked (skipped on dispatch failure or turn abort) | TurnPipelineMiddlewareFn |
dispatchInputPipeline | Dispatch | Once per iteration, before the executor | DispatchPipelineMiddlewareFn |
dispatchOutputPipeline | Dispatch | Once per iteration, after the executor | 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; dispatch-scoped pipelines see the DispatchContext, which adds the executor-iteration primitives (DispatchContext.iteration, DispatchContext.ack, DispatchContext.nack, DispatchContext.onAck, 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 — what conventionally goes in each of the four pipelines.
- Composition —
next(), sequencing, where to open a gate. - Throws — how throws are wrapped, why post-steps run on the error path, and the commit-vs-rollback pattern.
- Abort —
ctx.abort(reason),ctx.abortSignal, and classifying abort traffic from outside the runner. stash— cross-middleware state and isolation between turn and dispatch scopes.- Turn Runner, LLM Dispatch, Gates, Events.