---
url: 'https://adk.nht.io/the-loop/llm-dispatch.md'
description: >-
  DispatchRunner, the executor seam, the iteration loop, and the ack / nack
  lifecycle that bounds one dispatch.
---

# LLM Dispatch

## LLM summary — LLM Dispatch

* `DispatchRunner.dispatch({ source | raw, executor, turnInputPipeline?, turnOutputPipeline?, hooks?, observers? })` runs one execution cycle. Supply **exactly one** of `source` (a `TurnContext`) or `raw`. Both, or neither, throws `E_INVALID_LLM_DISPATCH_INPUT`.
* `dispatch()` is a first-class entry point. `TurnRunner.run()` calls it once per turn, but standalone consumers — planning agents, specialist agents, summarisers, replayers, evaluators — call it directly when they need a full dispatch cycle without a full turn.
* The runner is **single-use**. Per-id stream state on `DispatchExecutorHelpers` lives on closure-captured Maps and is garbage-collected with the runner; it cannot leak across dispatches.
* The loop is bounded by **signals only**. The ADK does not pick the limit for you — it gives you the information you need to pick yours: `ctx.iteration` is the running count; `ctx.toolCallCount(checksum)` exposes repetition; `ctx.tools` is the live registry. The ADK does not decide *when* to stop; it makes sure you can.
* A dispatch ends in exactly one of three states: `'ack'`, `'nack'`, or `'aborted'`. Status appears on `dispatchEnd.status`.
* Signalling is **NOT** silently idempotent. The first `ack()` or `nack()` sets the signal; a second call throws `E_LLM_EXECUTION_ALREADY_SIGNALLED`. Wrap with `if (!ctx.isSignalled)` if multiple seams may signal.
* Executor responsibilities, by convention, in order: (1) call the model, (2) stream via `helpers.reportMessage/reportThought/reportToolCall(id, ...)`, (3) **for any tool calls the model proposed this iteration, invoke them via `tool.executor(ctx)(args)`** so iteration N+1's model call can see the results, (4) persist via `ctx.storeMessage/storeThought/storeToolCall(record)`, (5) signal `ctx.ack()` or `ctx.nack(err)`. Tool handlers normally run *inside the executor*; they should not be re-invoked from `turnOutputPipeline` or after the loop, because that two-iteration round trip is the convention that makes the dispatch loop useful.
* Helpers stream the wire shape; persistence stores the canonical record. **Calling a `report*` without later calling the matching `store*` is almost always a bug.** The ADK can't tell — there are legitimate emit-only cases — so it's on the executor to be deliberate about it.
* Stores/mutations are queued as deltas during the iteration and flushed to the parent `TurnContext` Sets at the end of the iteration **only on the derived path** (`source:`). On the standalone path (`raw:`) the queue is drained but never bubbled. Abort or `nack` mid-iteration discards the queue.
* Bounds primitives: `ctx.iteration` (0-based), `ctx.toolCallCount(checksum)`, `ctx.onAck(handler)` (sync, fires only on `ack`, returns an unsubscribe; handler exceptions are swallowed individually).
* Error wrapping: executor throws → `E_LLM_EXECUTION_EXECUTOR_ERROR`; either dispatch pipeline throws → `E_DISPATCH_PIPELINE_ERROR` (one code for both input and output sides — the label is internal). Abort is not an error.
* Re-throw behaviour differs by entry point: when `TurnRunner` is the caller, dispatch errors are caught and emitted on the runner's `error` bus and `run()` still resolves. When dispatch is called standalone, the same errors **reject** the `dispatch()` promise.
* When asked "how do I detect an infinite tool loop" → middleware that watches `ctx.toolCallCount(checksum)` and calls `ctx.nack(new Error(...))`. The runner will not do it.

A dispatch is one LLM execution cycle. [`DispatchRunner`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/classes/DispatchRunner) constructs a single-use [`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext), runs the iteration loop, and resolves when the executor signals completion or the abort signal fires.

::: danger The loop is bounded by signals, not heuristics
There is no `maxIterations`, no checksum-repeat detector, no termination policy. What the ADK *does* give you is the information you need to make those decisions yourself: `ctx.iteration` (see [`DispatchContext.iteration`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-iteration)) for how many cycles you've spent, `ctx.toolCallCount(checksum)` (see [`DispatchContext.toolCallCount`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#toolcallcount)) for how often the model has asked for the same call, and `ctx.tools` (see [`DispatchContext.tools`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-tools)) for what is currently available to it. The decision of when to stop is yours — the primitives that let you make it are ours.
:::

## What a dispatch is

A dispatch is the bounded loop around one act of "calling the model" — including any iterations required to chase tool calls to completion. `DispatchRunner.dispatch()` is designed to run either inside a turn or standalone, with the same core behaviour in both scenarios. **What changes between the two paths is the wiring, not the loop.**

* **Inside a turn (`source:`).** [`TurnRunner.run()`](./turn-runner) calls `dispatch()` between the input and output middleware pipelines. The dispatch inherits the turn's collections, callbacks, abort signal, and event buses; mutations flow back to the turn at the end of each iteration; functional and observability events bubble up to the runner's listeners.
* **Standalone (`raw:`).** The caller assembles the context directly. There is no parent — collections, callbacks, hooks, and observers are wired by the caller of `dispatch()`. This is the entry point for planning agents proposing a sub-step, specialist agents handling a single tightly scoped reasoning task, summarisers running over a finished conversation, evaluators replaying a recorded trace, and anywhere else a full [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) would be too much machinery for the job at hand.

Both paths run the same loop, signal the same way, surface the same errors. The only differences are the wiring that connects the dispatch to its environment.

```ts
await DispatchRunner.dispatch({
  source: turnContext,         // OR raw: { ... } — exactly one
  executor: executorCallback,
  turnInputPipeline: [/* per-iteration input */],
  turnOutputPipeline: [/* per-iteration output */],
  hooks: { /* functional forwarders */ },
  observers: { /* observability forwarders */ },
})
```

* **`source`** is a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext). The dispatch context inherits the turn's collections, callbacks, abort signal, and event wiring. Mutations made during dispatch are queued as deltas and flushed back to the turn's `Set`s at the end of every iteration. This is the path the [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) uses.
* **`raw`** is the standalone path. Supply the raw fields directly. There is no parent context; nothing bubbles up, and the delta queue is drained but never applied to a parent.

::: warning Exactly one of `source` or `raw`
Supplying both, or neither, throws [`E_INVALID_LLM_DISPATCH_INPUT`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_INVALID_LLM_DISPATCH_INPUT) from `dispatch()` synchronously. When `TurnRunner` is the caller this surfaces as a wrapped error on the runner's `error` bus; when called standalone, the promise rejects.
:::

::: tip Single-use by construction
The runner is constructed inside `dispatch()`, runs the loop, and is garbage-collected. Per-id stream state on `DispatchExecutorHelpers` lives on closure-captured `Map`s and dies with the runner — it cannot leak across dispatches.
:::

## The iteration loop

```mermaid
flowchart TD
  S([dispatchStart]) --> L{aborted or signalled?}
  L -->|yes| END([dispatchEnd<br/>status: ack / nack / aborted])
  L -->|no| IS[iterationStart]
  IS --> IM[turnInputPipeline]
  IM -->|abort or signal| DISCARD1[discard delta queue]
  DISCARD1 --> END
  IM -->|ok| EX[executor ctx, helpers]
  EX -->|throws non-abort| WRAP[wrap as E_LLM_EXECUTION_EXECUTOR_ERROR<br/>emit error, discard deltas]
  WRAP --> END
  EX -->|abort or signal| DISCARD2[discard delta queue]
  DISCARD2 --> END
  EX -->|ok| OM[turnOutputPipeline]
  OM --> FLUSH[flush queued deltas<br/>derived: → parent TurnContext Sets<br/>standalone: drain only]
  FLUSH --> IE[iterationEnd]
  IE --> INC[iteration += 1]
  INC --> L

  click S "./events" "dispatchStart observability event"
  click END "./events" "dispatchEnd observability event"
  click IS "./events" "iterationStart observability event"
  click IE "./events" "iterationEnd observability event"
  click IM "./pipelines" "turnInputPipeline pipeline"
  click OM "./pipelines" "turnOutputPipeline pipeline"
  click WRAP "/api/@nhtio/adk/exceptions/variables/E_LLM_EXECUTION_EXECUTOR_ERROR" "Executor error wrapping"
```

::: danger The loop does not bound itself
If the executor never signals and never throws, the loop runs forever. Not "until the framework notices." Forever. Your cap is middleware — `ctx.iteration` (see [`DispatchContext.iteration`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-iteration)), `ctx.toolCallCount(checksum)` (see [`DispatchContext.toolCallCount`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#toolcallcount)) — or your outage is the cap.
:::

::: danger Tool handlers are conventionally invoked by the executor — inside the iteration that proposed them
When the model returns a tool call on iteration N, the executor invokes `tool.executor(ctx)(args)`, persists the completed [`ToolCall`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolCall) record via `ctx.storeToolCall(...)`, and only then returns. Move that work after the loop and the model never sees the result. Have middleware also invoke it and side effects fire twice. Iteration N+1's model call then sees the result in `ctx.turnToolCalls` and can reason about it. **That two-iteration round trip — model proposes → executor invokes handler → executor persists → next iteration sees result — is the convention that makes the dispatch loop useful.** Tool handlers should not be re-invoked in `turnOutputPipeline`, `dispatchOutputPipeline`, or other pipeline middleware; after the loop, the model never saw the result, and if the executor already ran the handler, middleware would double-fire side effects. See [The executor seam → Invoking tools](./llm-dispatch/executor-seam#invoking-tools).
:::

Any seam with access to `ctx` can signal — the executor, an input middleware, or an output middleware. Three things to know:

* **The signal is what ends the dispatch.** The loop checks `ctx.isSignalled` (see [`DispatchContext.isSignalled`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-issignalled)) after the input pipeline, after the executor, and at the top of every iteration. The first seam to call `ctx.ack()` (see [`DispatchContext.ack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#ack)) or `ctx.nack(error)` (see [`DispatchContext.nack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#nack)) wins; the loop breaks at the next check.
* **The executor sees one iteration at a time.** It has the raw provider response in hand and that is the basis on which it can decide to ack (the response is final) or nack (the API call failed). It does not, by itself, have visibility into what happened in earlier iterations beyond what is in `ctx`.
* **Middleware sees the whole dispatch.** `ctx.iteration` (see [`DispatchContext.iteration`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-iteration)), `ctx.toolCallCount(checksum)` (see [`DispatchContext.toolCallCount`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#toolcallcount)), and the turn-scoped collections (`ctx.turnMessages`, `ctx.turnToolCalls`, etc.) accumulate across iterations and are available to input and output middleware. Bounds, repetition detection, and any "we are done now" decision based on what has already happened belong here. `if (ctx.iteration >= 10) ctx.nack(...)` and `if (ctx.toolCallCount(checksum) >= 3) ctx.nack(...)` are the canonical shapes; an output middleware that inspects the latest tool call and acks when the dispatch's goal is met is the same idea.

Subsequent signals after the first throw [`E_LLM_EXECUTION_ALREADY_SIGNALLED`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_LLM_EXECUTION_ALREADY_SIGNALLED). When more than one seam may try to signal, read [`DispatchContext.isSignalled`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-issignalled) first.

## The executor seam

The executor is one callback. The runner invokes it once per iteration with the dispatch context `ctx` ([`DispatchContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext)) and the streaming helpers `helpers` ([`DispatchExecutorHelpers`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/interfaces/DispatchExecutorHelpers)). Helpers stream the wire shape; persistence (`ctx.storeMessage` / `ctx.storeThought` / `ctx.storeToolCall`) stores the canonical record. The executor's return signals — `ctx.ack()` (see [`DispatchContext.ack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#ack)), `ctx.nack(error)` (see [`DispatchContext.nack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#nack)), or returning without signalling — are how the runner decides what to do next.

→ Continue reading: [The executor seam](./llm-dispatch/executor-seam)

## Wiring a real provider

The quickstart uses a scripted executor. Replace [`TurnRunnerConfig.executorCallback`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnRunnerConfig#property-executorcallback) with your provider call when you are ready. The runner stays; only the body changes.

1. Read `ctx.systemPrompt`, `ctx.standingInstructions`, `ctx.turnMessages`, `ctx.turnMemories`, `ctx.turnRetrievables`, and `ctx.tools` (see [`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.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.tools`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-tools)).
2. Translate those primitives into your provider's request shape.
3. Stream model output back through `helpers.reportMessage(...)` or `helpers.reportThought(...)`.
4. Report tool calls through `helpers.reportToolCall(...)` if your model asks for tools.
5. Persist complete [`Message`](https://adk.nht.io/api/@nhtio/adk/common/classes/Message), [`Thought`](https://adk.nht.io/api/@nhtio/adk/common/classes/Thought), and [`ToolCall`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolCall) records through `ctx.store*` methods.
6. Call `ctx.ack()` (see [`DispatchContext.ack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#ack)) when the dispatch is done, or `ctx.nack(error)` (see [`DispatchContext.nack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#nack)) when it should fail.

ADK does not pick a provider, retry strategy, prompt format, or tool-calling protocol. That is your architecture. ADK keeps the turn loop deterministic while you own the rest.

## Signalling: `ack`, `nack`, `aborted`

A dispatch ends in exactly one of three terminal states. The first call to [`DispatchContext.ack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#ack) or [`DispatchContext.nack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#nack) sets the signal; a second call throws `E_LLM_EXECUTION_ALREADY_SIGNALLED` — it is *not* a silent no-op.

→ Continue reading: [Signalling: ack, nack, aborted](./llm-dispatch/signalling#signalling-ack-nack-aborted)

## `ctx.iteration`, `ctx.toolCallCount`, `ctx.onAck`

The dispatch context exposes three primitives for bounding the loop: a running iteration count (see [`DispatchContext.iteration`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-iteration)), a per-checksum tool call count (see [`DispatchContext.toolCallCount`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#toolcallcount)), and a synchronous lifecycle hook that fires only on `ack` (see [`DispatchContext.onAck`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#onack)).

→ Continue reading: [ctx.iteration, ctx.toolCallCount, ctx.onAck](./llm-dispatch/signalling#ctx-iteration-ctx-toolcallcount-ctx-onack)

## Forwarded events

Functional events (`message`, `thought`, `toolCall`) and observability events forward through the runner's hooks and back up to the [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner)'s buses when the dispatch is sourced from a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext). In the standalone path nothing bubbles up.

→ Continue reading: [Forwarded events](./llm-dispatch/signalling#forwarded-events)

## Errors during dispatch

Executor throws, middleware throws, abort, and `ctx.nack(error)` each settle the dispatch into one of the terminal states. Where the errors *surface* depends on whether the dispatch was called by `TurnRunner.run()` or standalone.

→ Continue reading: [Errors during dispatch](./llm-dispatch/errors)
