---
url: 'https://adk.nht.io/the-loop/llm-dispatch/signalling.md'
description: >-
  ack/nack/aborted terminal states, ctx.iteration/toolCallCount/onAck bounds
  primitives, and the forwarded event semantics.
---

# Signalling and bounds

The three terminal states a dispatch can settle in, the primitives the dispatch context exposes for bounding the loop, and how events forward when the dispatch is sourced from a turn versus standalone.

[LLM Dispatch](../llm-dispatch) covers the dispatch contract; [The executor seam](./executor-seam) covers how the executor calls these signals.

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

A dispatch ends in exactly one of three terminal **states**. `nack` is one of those states — it is not a synonym for "anything bad." A `nack` happens because a seam explicitly called `ctx.nack(error)`, or because a throw was caught and converted into one. Either way, the dispatch reaches the same terminal state with `dispatchEnd.error` carrying the cause.

| `dispatchEnd.status` | Cause |
| --- | --- |
| `'ack'` | [`DispatchContext.ack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#ack) was called and no `nackError` was set. |
| `'nack'` | [`DispatchContext.nack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#nack) was called. A non-abort throw from the executor or from input/output middleware is wrapped and converted into a `nack` — same terminal state, just reached implicitly instead of by explicit signal. `dispatchEnd.error` carries the cause. |
| `'aborted'` | The abort signal fired before any signal was set. The pending delta queue is discarded. |

::: warning Signalling is *not* silently idempotent
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`](https://adk.nht.io/api/@nhtio/adk/exceptions/variables/E_LLM_EXECUTION_ALREADY_SIGNALLED)** — it is not a no-op. If multiple seams in your pipeline may try to signal (e.g. an output middleware that completes on "no further tool calls" and an executor that also tries to ack), guard with `if (!ctx.isSignalled) ctx.ack()`. Read [`DispatchContext.isSignalled`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-issignalled), [`DispatchContext.isAcked`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-isacked), and [`DispatchContext.nackError`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-nackerror) to inspect signal state without provoking the exception.
:::

Local `DispatchContext` Sets and persistence callbacks are written immediately as `store` / `mutate` / `delete` are called. The *parent* `TurnContext` Set mirror is a separate delta queue that flushes at the iteration boundary: on a successful iteration the queue flushes before `iterationEnd`, on a mid-iteration `ack` it also flushes before exit, and on `nack` or abort the queue is discarded so the parent turn does not see partial mirror writes.

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

The dispatch context exposes the primitives needed to build behavior on top of the loop.

* **[`DispatchContext.iteration`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#property-iteration)** — 0-based index of the current iteration. Use this to bound retries (`if (ctx.iteration >= 10) ctx.nack(new Error('too many iterations'))`).
* **`ctx.toolCallCount(checksum)`** (see [`DispatchContext.toolCallCount`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#toolcallcount)) — count of tool calls with this checksum *stored* in this dispatch (only `ctx.storeToolCall` and the initial `toolCalls` seed bump the count; `helpers.reportToolCall` emits to the bus but does not). Use this to detect models stuck in a loop calling the same tool with the same args.
* **`ctx.onAck(handler)`** (see [`DispatchContext.onAck`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#onack)) — register a handler that runs synchronously when [`DispatchContext.ack`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#ack) fires. **Does not run on `nack`.** Returns an unsubscribe function. This is the lifecycle hook that [[`ToolRegistry.bindContext`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolRegistry#bindcontext)`(ctx)`](../tools#bindcontext) uses to prune ephemeral tools.

::: warning `onAck` handler errors are swallowed
Handlers registered via `onAck` are invoked synchronously in registration order. If one throws, the exception is caught and dropped so that one misbehaving subscriber cannot prevent the others from running. The `ack` itself has already succeeded — there is no place to surface the error. **If you need to observe failures, log inside the handler.** That is the only chance you get.
:::

The runner does not implement bounds, retries, or de-duplication on your behalf. These primitives are what you build them out of.

## Forwarded events

Functional events emitted by the dispatch context (`message`, `thought`, `toolCall`) forward through the runner's hooks and back up to the [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner)'s functional bus (see [`TurnRunner.on`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner#on)) when the dispatch is sourced from a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext). Same for observability events (`toolExecutionStart`, `toolExecutionEnd`, plus the dispatch-level `dispatchStart` / `dispatchEnd` / `iterationStart` / `iterationEnd` / `log` / `error`). See [Events](../events) for the full payload shapes.

In the standalone path (`raw:` instead of `source:`), nothing bubbles up — the caller of `dispatch()` is the only listener, and they wire `hooks` / `observers` directly into the dispatch input.
