---
url: 'https://adk.nht.io/the-loop/pipelines/composition.md'
description: >-
  The pipeline in depth — the function shape, next(), strict array sequencing,
  where to open a gate, and how short-circuits are reported.
---

# Composition

## LLM summary — Composition

* Each middleware is a function the runner hands two arguments: the context (a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext) or [`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext)) and a `next` continuation. The function returns nothing. The work is whatever it reads and writes on the context; `next` is the hand-off to the rest of the pipeline.
* Call `await next()` exactly once. Work before runs as a pre-step (downstream middlewares have not run yet); work after runs as a post-step (downstream middlewares have finished). Skipping `next()` is detected: if a middleware returns without calling `next()` and the turn was not aborted, the pipeline short-circuits and [`E_PIPELINE_SHORT_CIRCUITED`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_PIPELINE_SHORT_CIRCUITED) is emitted on the observability bus. Use `ctx.abort(reason)` for deliberate refusal.
* Sequencing within a pipeline is strict array order. No DAG, no priority, no implicit dependency resolution. If middleware B depends on A having populated `ctx.stash`, A must come before B in the same pipeline array.
* Where you open a gate decides what blocks. Before `next()` blocks every downstream middleware in the same pipeline; after `next()` blocks only this middleware's post-step; inside a tool handler blocks that dispatch iteration.
* Failure and abort are covered on [Throws](./throws) and [Abort](./abort) — throws are wrapped and emitted, post-steps run on the error path, and abort is silent on the error bus.

[Pipelines](../pipelines) covers what the four pipelines are and which context each one writes to. This page is the next level down: the contract a single middleware signs, what happens around the one call to `next()`, and where to open a gate. The failure modes the runner attaches to that contract live on [Throws](./throws) and [Abort](./abort).

## The function shape

A middleware is a function. The runner hands it the context and a continuation called `next`. The function returns nothing — the context is the work surface. Turn-scoped middleware sees a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext); dispatch-scoped middleware sees an [`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext).

`next()` is the hand-off to the rest of the pipeline. Call it to run downstream; `await` it to suspend until downstream has finished. One function body, both legs of the trip: pre-step before, post-step after.

Call `await next()` exactly once. Skip it and downstream never runs, but upstream unwinds normally — every upstream `await next()` resolves, every upstream post-step runs. The skip is detected (callout below). A second `next()` is a silent no-op; treat it as a bug. Calling without `await` races your pre-step against downstream work — if your middleware genuinely has no post-step, write `return next()`. Neither of those last two is detected. Write your middleware right.

::: danger Skipping `next()` emits an error; use `ctx.abort()` to refuse
A pipeline that does not run to completion is reported, not silent. When a middleware returns without calling `next()` and the turn was not aborted, the runner emits [`E_PIPELINE_SHORT_CIRCUITED`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_PIPELINE_SHORT_CIRCUITED) on the observability bus, labelled with the seam (`turn-input`, `turn-output`, `dispatch-input`, `dispatch-output`).

That is the right behaviour for a *bug*. It is the wrong behaviour for an intentional refusal — a refusal is not an error, and should not light up error alerting. The channel for refusal is `ctx.abort(reason)`. Once you call it, the runner stops invoking middleware bodies: the aborting middleware finishes its own body inline (so any cleanup you write between `ctx.abort()` and `return` runs — but if you `return` before `await next()`, there is no post-step, because a post-step is by definition code that runs *after* `next()` resolves), every middleware after it in the same pipeline is skipped, and the next major stage of the turn is skipped too. Upstream middlewares' post-steps still run normally on the unwind. `turnEnd` still fires and no `error` event is emitted. The operational signal depends on where the abort landed: if dispatch had already started, `dispatchEnd.status === 'aborted'`; if it had not (abort during `turnInputPipeline`), there is no `dispatchEnd` at all and the signal is `turnEnd` plus the absence of `dispatchStart`. The `reason` is available on `ctx.abortSignal.reason` from inside the pipeline but is not carried on `dispatchEnd` — capture it via observability from inside the abort source if you want to log *why*. Refusal becomes observable, intentional, and indistinguishable only from other intentional aborts — which is the point.

There is no good reason to bare-skip `next()`. If your middleware has nothing to do this turn, still call `next()` — doing nothing in your own body is not the same as stopping the pipeline. If you want the pipeline to stop, that *is* a decision, and the channel for it is `ctx.abort(reason)`. A bare skip is always one of two things: the bug the detector reports, or the abort you should have spelled out. Reach for `ctx.abort()`.
:::

## Sequencing

::: warning Strict array order
The pipelines are arrays. The runner invokes middleware in the order you wrote, every time. No DAG, no priority, no `dependsOn`. If B reads `ctx.stash` that A wrote, A goes first.
:::

A dependency-resolving system gives you implicit ordering at the cost of "why did B run before A this time" being a real debugging question. An array gives you one ordering — the one you wrote, the same on every turn. Composition is a code-review concern, not a runtime mystery.

The cost is yours. If A and B both write the same `ctx.stash` key, last write wins. If B reads a key A never set, B sees `undefined`. The runner enforces array order and nothing more.

## Where to open a gate

::: tip TL;DR
`await ctx.waitFor(rawGate)` blocks exactly the scope you put it in:

* In `turnInputPipeline` / `turnOutputPipeline` → **the turn pauses**.
* In `dispatchInputPipeline` / `dispatchOutputPipeline` → **the dispatch iteration pauses**.
* Inside a tool handler → **that tool-call resolution pauses**.

Before `next()`: downstream pipeline stops. After `next()`: your post-step stops. Other turns running on the same runner keep moving. There is no magic background lane.
:::

A middleware that needs to suspend calls `await ctx.waitFor(rawGate)`. The runner opens a [`TurnGate`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate) and returns a promise that resolves when the gate settles. From the caller's perspective, `runner.run()` does not resolve until every gate on that turn has settled or the turn aborts.

**Where in the pipeline you open it decides what blocks.**

* **Before `next()`** holds every downstream middleware in the same pipeline. Approval gates that must run before the prompt is packed open here — the model never sees the request until the gate resolves.
* **After `next()`** holds only this middleware's post-step. Human review of model output opens here, in `turnOutputPipeline` — the model has already produced; the gate decides what reaches the caller.
* **Inside a tool handler** pauses tool execution; the current iteration cannot complete until the handler returns. Tool-execution approval — "may this tool call run?" — opens here.

What does *not* pause: other turns. Each `runner.run()` is its own promise chain; a gate on turn A does nothing to turn B. Aborting a turn rejects every gate it owns with [`E_TURN_GATE_ABORTED`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_TURN_GATE_ABORTED); gates on other turns are unaffected.

[Gates](../gates) covers what gates *are* and are not, the canonical applications, durability, and settlement.

## Where to go next

* [Throws](./throws) — how throws are wrapped, why post-steps run on the error path, and the commit-vs-rollback pattern.
* [Abort](./abort) — `ctx.abort(reason)`, `ctx.abortSignal`, and classifying abort traffic from outside the runner.
* [Pipelines](../pipelines) — the hub.
* [What each pipeline owns](./what-each-pipeline-owns) — canonical responsibilities.
* [`stash`](./stash) — cross-middleware state.
* [Gates](../gates), [Failure](../failure).
