---
url: 'https://adk.nht.io/the-loop/pipelines/turn-scoped.md'
description: >-
  turnInputPipeline and turnOutputPipeline — bookends of a turn. Run once before
  dispatch, once after. Where turn-level cost lives.
---

# Turn-scoped pipelines

::: tip TL;DR
**Think of these two as the bookends of a turn.** The input pipeline runs once before the model is dispatched — it assembles everything the model is about to see. The output pipeline runs once after the dispatch is done — it handles whatever has to happen *after* the turn has settled (post-hoc safety, memory updates, telemetry).

The cost rule: anything you put here runs **once per turn**. A ten-iteration dispatch does not multiply this work. That's the whole reason these pipelines exist as a separate scope from [dispatch-scoped](./dispatch-scoped) ones.
:::

Both turn-scoped pipelines see the [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext). Neither sees the dispatch primitives — those live on [`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext) and are only visible inside the dispatch loop.

## `turnInputPipeline` {#input-middleware}

Turn-scoped. Fires once, before dispatch. Context: [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext).

The turn just arrived. `ctx.turnMessages`, `ctx.turnMemories`, `ctx.turnRetrievables` are empty. `ctx.tools` contains whatever you configured on the runner (or what `fetchTools` resolved). The executor sees exactly what middlewares in this pipeline put on the turn collections. Empty `turnInputPipeline` → empty context → the model responds to nothing. That is the default. The model gets the silence you ship.

* **Retrieve [`Retrievable`](https://adk.nht.io/api/@nhtio/adk/common/classes/Retrievable) records** into `ctx.turnRetrievables`. Declare [`Retrievable.trustTier`](https://adk.nht.io/api/@nhtio/adk/common/classes/Retrievable#property-trusttier) at the source — the prompt battery uses it to pick the envelope. See [Trust Tiers](../trust-tiers).
* **Load [`Memory`](https://adk.nht.io/api/@nhtio/adk/common/classes/Memory) records.** Score, filter, write into `ctx.turnMemories`.
* **Pack history.** `await ctx.fetchMessages()`, decide how much to surface, trim to a budget. See [Budgets](../budgets).
* **Enforce inbound policy.** Refuse turns *before* the model sees them. A throw wraps as `E_INPUT_PIPELINE_ERROR`; dispatch and output are skipped, `turnEnd` still fires. Intentional refusal uses `ctx.abort(reason)` — see [Abort](./abort).

Turn-level cost belongs here. Move retrieval into `dispatchInputPipeline` and you pay for it every iteration — ten iterations, ten bills. Work that does not depend on what the model just said does not belong in the dispatch loop.

## `turnOutputPipeline` {#output-middleware}

Turn-scoped. Fires once, after dispatch. Context: [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext).

The dispatch is done. In the typical setup the executor invoked tool handlers during dispatch (that is what made the iteration loop useful — the next iteration's model call saw the results), and the persistence callbacks have already fired for whatever the executor stored via `ctx.storeMessage` / `storeThought` / `storeToolCall`. What `turnOutputPipeline` owns is *post-hoc* turn work: anything that has to happen once after the dispatch has settled, not a second handler invocation.

* **Post-hoc safety.** `await ctx.fetchMessages()` (and the matching `fetchThoughts` / `fetchToolCalls` if you wire them) to see what landed in storage this turn, then `ctx.mutateMessage(...)` / `mutateThought` / `mutateToolCall` to rewrite or annotate. Cheaper than refusing in `dispatchOutputPipeline` if your check needs the full record assembled.

::: warning Do not trust `ctx.turnMessages` here as your only source of truth
In early-ack paths the record was persisted, but the parent Set may not show it. Fetch from storage when the check depends on the completed record. The Set is a convenience; storage is the receipt.
:::

* **Update memories.** `ctx.storeMemory(...)` / `ctx.mutateMemory(...)` — memories that should reflect what the *whole* turn said, not what a single iteration produced.
* **Turn telemetry.** `ctx.stash` from `turnInputPipeline` is still here — turn-scoped fields survive the dispatch loop unchanged.

A throw from any middleware in this pipeline wraps as `E_OUTPUT_PIPELINE_ERROR` and skips the remaining downstream middlewares. Order the pipeline so the most important writes happen first, and let post-steps do the cleanup — [Throws](./throws) covers the contract.

**This pipeline only runs on `ack`.** A `nack` or abort skips it (`turnEnd` still fires). If you put required cleanup or failure reporting here, you just made it success-only by accident.

## Where to go next

* [Dispatch-scoped pipelines](./dispatch-scoped) — the per-iteration sibling pipelines.
* [Pipelines](../pipelines), [Composition](./composition), [`stash`](./stash).
