---
url: 'https://adk.nht.io/the-loop/turn-runner/inside-one-turn.md'
description: >-
  The pipeline diagram, the four invariants, and the two event buses around one
  turn.
---

# Inside one turn

The full pipeline diagram for `runner.run(rawCtx)`, the four invariants that govern error paths, and the two event buses the runner exposes.

[Turn Runner](../turn-runner) covers the construction contract and the [`TurnContext`](../turn-runner#turncontext) shape.

## The pipeline diagram

```mermaid
flowchart TD
  R([RawTurnContext]) --> V{turnContextSchema}
  V -->|invalid| XV([E_INVALID_TURN_CONTEXT])
  V -->|valid| C[new TurnContext<br/>id assigned · callbacks bound<br/>sets empty · ToolRegistry seeded]
  C --> TS[emit turnStart]
  TS --> IM[turnInputPipeline<br/>retrieval · memory load · history pack · policy]
  IM -->|throws| EI[E_INPUT_PIPELINE_ERROR<br/>emit error · skip dispatch]
  IM --> D[DispatchRunner.dispatch<br/>executor + llmInput / llmOutput middleware]
  D -->|throws| ED[emit error<br/>skip output middleware]
  D --> OM[turnOutputPipeline<br/>tool dispatch · persistence · refusal filter · telemetry]
  OM -->|throws| EO[E_OUTPUT_PIPELINE_ERROR<br/>emit error]
  OM --> TE([emit turnEnd])
  EI --> TE
  ED --> TE
  EO --> TE

  click XV "/api/@nhtio/adk/exceptions/variables/E_INVALID_TURN_CONTEXT" "E_INVALID_TURN_CONTEXT"
  click IM "../middleware#input-middleware" "Input middleware pipeline"
  click EI "/api/@nhtio/adk/exceptions/variables/E_INPUT_PIPELINE_ERROR" "E_INPUT_PIPELINE_ERROR"
  click D "../llm-dispatch" "DispatchRunner.dispatch"
  click OM "../middleware#output-middleware" "Output middleware pipeline"
  click EO "/api/@nhtio/adk/exceptions/variables/E_OUTPUT_PIPELINE_ERROR" "E_OUTPUT_PIPELINE_ERROR"
  click TE "../events#turn-end" "turnEnd observability event"
  click TS "../events#turn-start" "turnStart observability event"
```

::: tip
Every node is a link. If a stage surprises you, click it before you patch around it.
:::

## Four invariants the diagram does not show

These matter precisely when something goes wrong. Get them wrong and your error paths will silently disagree with your code.

::: danger 1. `turnEnd` always fires
Every terminal node — clean exit, input failure, dispatch failure, output failure — funnels into `turnEnd`. If you wire telemetry to exactly one event, wire it here. Nothing else is guaranteed.
:::

::: danger 2. `run()` does not reject on pipeline failure
Errors emit on the [observability bus](#two-event-buses) as `error` and skip the next stage of the turn — a throw in input middleware skips dispatch and output; a throw in dispatch skips output; a throw in output ends the turn. Within the failing pipeline itself, the harness wires an error handler that catches throws at every level, so upstream post-steps still resume as if downstream had finished. The catch site for the error is your observer, **not** a `try/catch` around `run()`. Tool-handler errors and [`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) failures during dispatch are wrapped and re-emitted as `error` too — they also do not throw out.
:::

::: warning One honest caveat on invariant 2
There is exactly one path that does reject `run()` synchronously: schema validation of the raw turn context. Before any pipeline runs, the runner constructs a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext) from the raw input, and `turnContextSchema` is checked inside that constructor. A bad shape throws [`E_INVALID_TURN_CONTEXT`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_INVALID_TURN_CONTEXT) out of `run()` as a rejected promise — there is no observer to emit to yet, because the turn has not started, and `turnStart` / `turnEnd` do not fire. This is the difference between *pipeline* errors (caught and emitted) and *pre-pipeline* errors (thrown). If you `await runner.run(rawCtx)` with no `try` / `catch`, an invalid `rawCtx` will reject the await. Either guard the call or validate upstream.
:::

::: danger 3. Abort is silent by design
When the abort signal fires — or middleware throws an `AbortError` — the pipeline short-circuits with no `error` event. `turnEnd` still fires. The consumer's abort handler owns any user-visible signal; the runner refuses to invent one.
:::

::: danger 4. The dispatch loop is its own contract
The middle of this diagram is a single box on purpose. The iteration loop, `ack` / `nack` semantics, and the executor seam live in [LLM Dispatch](../llm-dispatch). Treating dispatch as a black box from the runner's vantage is the whole point of the seam.
:::

## Two event buses

[`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) exposes two event interfaces. The methods look symmetric — they are not.

```ts
// Functional bus — participates in product behavior
runner.on('message', listener)
runner.once('thought', listener)
runner.off('toolCall', listener)

// Observability bus — instrumentation only
runner.observe('turnStart', listener)
runner.observeOnce('turnEnd', listener)
runner.unobserve('error', listener)
```

(See [`TurnRunner.on`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#on), [`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).)

::: warning The rule, repeated everywhere it matters
If removing the listener changes the agent's behavior, it belongs on the functional bus. If your `observe('error')` handler decides what the user sees, you wired product behavior to telemetry. Move it.
:::

Streaming a message to a UI is functional — remove the listener, the user sees nothing. Recording a span is observability — remove the listener, the agent behaves identically. The test is mechanical, not aesthetic.

| Bus | Events | Use it for |
| --- | --- | --- |
| Functional | `message`, `thought`, `toolCall` | Streaming content to the UI, threading model output into downstream behavior, anything that has to happen for the agent to function. |
| Observability | `turnStart`, `turnEnd`, `dispatchStart`, `dispatchEnd`, `iterationStart`, `iterationEnd`, `turnGateOpen`, `turnGateClosed`, `toolExecutionStart`, `toolExecutionEnd`, `log`, `error` | Spans, metrics, audit logs, debug UIs, telemetry sinks. |

The split is structural, not stylistic. The two buses run on separate emitters so the surface itself enforces the rule: a functional listener and an observability listener cannot end up on the same event, and code reaching for `observe()` is code that has declared "removing this changes nothing." Telemetry can be wired without auditing whether the listener accidentally became load-bearing. If your `observe('error', …)` decides whether the user sees a refusal, you put it on the wrong bus.

See [Events](../events) for payload shapes and the `aDelta` / `full` / `isComplete` semantics on the streaming events.
