---
url: 'https://adk.nht.io/the-loop/turn-runner.md'
description: >-
  The TurnRunner entry point: eager config validation, the TurnContext, callback
  boundaries, and the two event buses around one turn.
---

# Turn Runner

## LLM summary — TurnRunner

* `new TurnRunner(config)` validates `config` synchronously against `turnRunnerConfigSchema`. Misconfiguration throws `E_INVALID_TURN_RUNNER_CONFIG`. No lazy/partial state — if `new` returned, the runner is complete.
* `TurnRunnerConfig` requires **27 storage callbacks** (7 retrieval + 18 persistence) covering messages, memories, thoughts, tool calls, retrievables, tools, and standing instructions. The four middleware arrays (`turnInputPipeline`, `turnOutputPipeline`, `dispatchInputPipeline`, `dispatchOutputPipeline`) and `tools` are optional and default to `[]`. A missing required callback is a construction error, not a silent default.
* A `noop` callback is a valid declaration; an omitted callback is not. The ADK will not invent a fallback because the boundary between an agent and its store is your contract. If you mean "no memory," return `[]`; if you mean "later," throw `E_NOT_IMPLEMENTED`.
* `runner.run(rawCtx)` validates input (`turnContextSchema` → `E_INVALID_TURN_CONTEXT`), builds a fresh `TurnContext` per call (UUIDv6 `id`, bound storage callbacks, empty per-turn `Set`s for `turnMessages` / `turnMemories` / `turnRetrievables` / `turnThoughts` / `turnToolCalls`, fresh `ToolRegistry` seeded from `config.tools`), and threads it through the pipeline.
* `TurnContext` is mutable only in place. Per-turn `Set`s are read-only references — `add`/`delete` work, assignment does not. `stash` is patched in place.
* `run()` resolves `Promise<void>` for every *pipeline* outcome — clean, input failure, dispatch failure, output failure, abort. Errors emit on the observability bus as `error`. Your observer is the catch site. Tool-handler errors and `ack`/`nack` failures during dispatch are also wrapped and re-emitted as `error`, not thrown.
* **One exception, pre-pipeline:** if the raw turn context fails `turnContextSchema` validation (run inside the `TurnContext` constructor, before any pipeline starts), `E_INVALID_TURN_CONTEXT` rejects out of `run()` synchronously. No `turnStart` / `turnEnd` / `error` fires for this path — there is no observer for a turn that never started. Guard the `await` or validate upstream.
* `turnEnd` always fires — clean exit, input failure, dispatch failure, output failure. It is the one reliable terminal event.
* Abort is silent by design: no `error` event, but `turnEnd` still fires. Your abort handler owns any user-visible signal.
* Two event buses: the functional bus exposes `on`/`off`/`once` listener methods on [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) for `message`/`thought`/`toolCall`, and middleware emits via `ctx.emitMessage`/`ctx.emitThought`/`ctx.emitToolCall` — there is no public `runner.emit`. Observability `observe`/`unobserve`/`observeOnce` cover everything else (`turnStart`, `turnEnd`, `dispatchStart`, `dispatchEnd`, `iterationStart`, `iterationEnd`, `turnGateOpen`, `turnGateClosed`, `toolExecutionStart`, `toolExecutionEnd`, `log`, `error`).
* The functional/observability rule: **if removing the listener changes agent behavior, it is functional.** No exceptions. The buses run on separate emitters so an observer cannot block a functional emission.
* `ctx.waitFor(gate)` is a sequential pipeline await — it blocks downstream of the awaiter, not magically only the awaiter. A gate awaited before `next()` in middleware holds every downstream middleware in that pipeline; a gate inside a tool handler holds the dispatch iteration; other turns on the same runner are unaffected. Settlements: resolved (optional schema validates first; failed validation throws `E_INVALID_TURN_GATE_RESOLUTION` synchronously and leaves the gate open), rejected, aborted (`E_TURN_GATE_ABORTED`), timed out (`E_TURN_GATE_TIMEOUT`). All four emit `turnGateClosed`. See [Gates](./gates).
* The runner is stateless across turns. `run()` can be called concurrently or repeatedly. Multiple runners with overlapping config share nothing.
* The runner does not retry, bound iterations, impose policy, interpret `Message.role`, decide when to call tools, or trim context. Those behaviors live in the executor and middleware.

[`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) is the entry point for one turn. It validates its config at construction, validates the raw turn input at `run()` time, builds a [`TurnContext`](#turncontext), and threads that context through input middleware → [LLM dispatch](./llm-dispatch) → output middleware. Anything you need to see leaves through the [event buses](./turn-runner/inside-one-turn#two-event-buses).

`run()` returns `Promise<void>`. The return value is a completion signal, not a result — if you wrote code that reaches into a `.then(result => …)` looking for the model's reply, you are reading a different library.

::: tip "But I just want the final assistant message" — here is the short answer
Three questions land here on first contact. The answers are not buried; they are spread across pages, so here they are in one place.

**Q. `run()` returns `void`. How do I get the final assistant message?**

You subscribe before you call `run()`. The `message` event fires for each streaming delta and once more with `isComplete: true` carrying the full assembled text:

```ts
let final = ''
runner.on('message', (event) => {
  if (event.isComplete) final = event.full   // final assistant text for this stream
})
await runner.run(rawCtx)
// `final` now holds the assistant's reply.
// Alternatively, read `ctx.turnMessages` from a piece of output middleware:
// the last `assistant`-identity Message in that Set is the final reply,
// and the record carries identity, payload, and provenance — not just text.
```

`event.aDelta` is the incremental chunk for token-level rendering; `event.full` is the accumulated text so far; `event.isComplete: true` marks the last emission for a given `id`. There is no `runner.getFinalMessage()` because `run()` is not a result API. By the time you would call it, the data has already left through the bus, and one turn can produce multiple assistant streams. Subscribe before `run()`, or read canonical records from middleware. Waiting until after `run()` and asking the runner for "the answer" is the wrong library.

**Q. Which event is guaranteed to fire?**

`turnEnd` — always. Clean exit, dispatch failure, output failure, even abort. If you need to know when a turn is *over*, observe `turnEnd`. If you need to know whether it failed, observe `error` (which fires *before* `turnEnd` for any non-fatal pipeline failure — see [Failure](./failure)).

**Q. Do I have to render streaming deltas to use this?**

No. `on('message', …)` fires for every chunk, but you can ignore everything except the `isComplete: true` emission and treat it as a "message arrived" callback. If you only care about the persisted record (identity, payload, provenance), read `ctx.turnMessages` from output middleware — the `message` event is a wire-shape stream, the `Set` on `ctx` is the canonical record.

The rule that unifies all three: **the buses are the only egress.** `run()` is a control-flow signal. See [Events](./events) for the full grid.
:::

## Construction is validation

```ts
const runner = new TurnRunner(config)
```

The constructor runs `turnRunnerConfigSchema` against `config` and throws [`E_INVALID_TURN_RUNNER_CONFIG`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_INVALID_TURN_RUNNER_CONFIG) on failure. No async, no lazy init, no "we'll figure it out on the first turn." If `new` returned, the runner is complete.

::: danger A misconfigured runner does not exist
Every required callback is present, every middleware array is iterable, every schema is parsed — or construction throws. There is no third state. The first turn never starts because the configuration was wrong; it starts because the configuration was right.
:::

The required surface is the **twenty-seven storage callbacks** on [`TurnRunnerConfig`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnRunnerConfig) — seven retrieval, eighteen persistence, and two byte-persistence conduits, covering messages, memories, thoughts, tool calls, retrievables, tools, and standing instructions — plus the required [`TurnRunnerConfig.executorCallback`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnRunnerConfig#property-executorcallback) that drives the model dispatch. The middleware arrays ([`TurnRunnerConfig.turnInputPipeline`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnRunnerConfig#property-turninputpipeline), `turnOutputPipeline`, `dispatchInputPipeline`, `dispatchOutputPipeline`) and `tools` are optional and default to an empty array, normalised at construction so internal access can assume present, iterable values.

::: warning You can opt out. You cannot omit.
A no-op `storeMessage` is fine. A `fetchMemories` that returns `[]` is fine. The ADK will run, and the agent will behave exactly as you wired it — without persistence, without history, without recall.

What the ADK refuses is the *missing entry*. The runner has no default to fall back to, because the only safe default for the boundary between an agent and its store is the one you chose. Persistence is not the kind of thing that gets deferred — it is the kind of thing that gets *missed*, and the cost shows up in production as an agent that loses every conversation or wakes up amnesiac every turn.

So you write the callback. If you mean "no memory," return `[]` and move on. If you mean "memory later," throw `E_NOT_IMPLEMENTED` so the first turn fails loudly. Either is a declaration. Nothing is not.
:::

The runner is stateless across turns. Call `run()` repeatedly, concurrently, or both. The runner stores no cross-turn state, and multiple runners with overlapping config share nothing.

## TurnContext

[`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext) is built fresh per turn from the [`RawTurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/RawTurnContext) passed to `run()`. Validation (`turnContextSchema`) throws [`E_INVALID_TURN_CONTEXT`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_INVALID_TURN_CONTEXT) on failure. There is one `TurnContext` per call to `run()` — it never leaks, it never gets reused, and there is no global "current turn."

Every context the runner builds carries:

| Field | What it is | Owned by |
| --- | --- | --- |
| [`TurnContext.id`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-id) | UUIDv6 for correlation across all events emitted during the turn. | Runner. |
| `turnAbortController`, [`TurnContext.systemPrompt`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-systemprompt), [`TurnContext.standingInstructions`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-standinginstructions), [`TurnContext.stash`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-stash) | The raw fields you supplied on `RawTurnContext`. | Consumer. |
| `ctx.fetch*` / `ctx.store*` / `ctx.mutate*` / `ctx.delete*` | The runner's storage callbacks, bound. | Runner-bound, consumer-implemented. |
| [`TurnContext.turnMessages`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-turnmessages), [`TurnContext.turnMemories`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-turnmemories), [`TurnContext.turnRetrievables`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-turnretrievables), [`TurnContext.turnThoughts`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-turnthoughts), [`TurnContext.turnToolCalls`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-turntoolcalls) | Per-turn `Set`s that start empty. Earlier middleware fills them; later middleware reads them. | Middleware. |
| [`TurnContext.tools`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-tools) | Fresh [`ToolRegistry`](./tools) seeded from `config.tools`. Per-turn `register` / `unregister` / `merge` mutate this turn only. | Middleware. |
| `emit*` | Wired into the runner's [event buses](./turn-runner/inside-one-turn#two-event-buses). | Runner. |
| `openGate`, [`TurnContext.waitFor`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-waitfor) | The [gate machinery](./turn-runner/gates-and-non-goals#gates-and-ctx-waitfor). | Runner. |

The boundary is reached through `ctx`. Middleware calls `ctx.fetchMessages()`, not the bare `fetchMessagesCallback` from config. The same goes for storage, emission, and gates. Closure capture of the raw callbacks is a code smell — `ctx` exists so the runner can bind, count, and observe every crossing.

::: warning In-place mutation only
The per-turn collections are read-only references to mutable `Set`s. You add to them (`ctx.turnMemories.add(m)`); you do not replace them (`ctx.turnMemories = new Set()` will fail). The shape of `stash` is yours, but the slot is the slot — middleware patches `stash` in place. There is no API for swapping whole references, because there is no point in the loop where doing so would be safe.
:::

## Inside one turn

The full pipeline — [`RawTurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/RawTurnContext) validation → [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext) construction → `turnStart` → input middleware → dispatch → output middleware → `turnEnd` — plus the four invariants that govern error paths and the two event-bus contract.

→ Continue reading: [Inside one turn](./turn-runner/inside-one-turn)

## Two event buses

[`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) exposes a **functional** bus ([`TurnRunner.on`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#on) / [`TurnRunner.off`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#off) / [`TurnRunner.once`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#once)) for events that participate in product behavior — `message`, `thought`, `toolCall` — and an **observability** bus ([`TurnRunner.observe`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#observe) / [`TurnRunner.unobserve`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#unobserve) / [`TurnRunner.observeOnce`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#observeonce)) for everything else. The rule: if removing the listener would change agent behavior, it belongs on the functional bus.

→ Continue reading: [Two event buses](./turn-runner/inside-one-turn#two-event-buses)

## Gates and `ctx.waitFor`

`ctx.waitFor(gate)` opens a [`TurnGate`](../api/) — the cooperative suspension primitive that every safety, authorization, and human-oversight feature attaches to. The runner owns the lifecycle; you own who can resolve and how the gate is surfaced.

→ Continue reading: [Gates and ctx.waitFor](./turn-runner/gates-and-non-goals#gates-and-ctx-waitfor)

## What `run()` does not do

`run()` does not retry, bound iterations, impose policy, interpret `Message.role`, decide when to call tools, or trim context. Those are behaviors — and behaviors are yours.

→ Continue reading: [What run() does not do](./turn-runner/gates-and-non-goals#what-run-does-not-do)

## Wiring real storage

Every fetch and mutation callback is explicit because storage is not a side quest. It is where your product's guarantees live.

For a prototype, noop callbacks are fine. For production, point them at your stores:

* `storeMessageCallback` / `fetchMessagesCallback` — your conversation history table
* `storeMemoryCallback` / `fetchMemoriesCallback` — your memory or profile store
* `storeRetrievableCallback` / `fetchRetrievablesCallback` — your RAG index
* `storeToolCallCallback` / `fetchToolCallsCallback` — your audit or event log
* `storeStandingInstructionCallback` / `refreshStandingInstructionsCallback` — your tenant, user, or deployment configuration

The runner injects those callbacks into each turn context, so middleware can fetch, mutate, and persist through `ctx` without knowing which database, cache, or service backs the operation. The callback is the boundary. What is on the other side is your choice.
