---
url: 'https://adk.nht.io/the-loop/events.md'
description: 'Two event buses, two jobs, and the rule that separates them.'
---

# Events

## LLM summary — Events

* [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) has two event buses. **If removing the listener would change the agent's behavior, it's functional. Otherwise, observability.** No exceptions.
* **Functional bus** (`runner.on` / `off` / `once`): `message`, `thought`, `toolCall`. Streaming content. The user sees nothing if no one listens. `message` and `thought` carry [`TurnStreamableContent`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnStreamableContent); `toolCall` carries [`TurnToolCallContent`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnToolCallContent). Streams accumulate per `id` and seal when an emit carries `isComplete: true`.
* **Observability bus** (`runner.observe` / `unobserve` / `observeOnce`): `turnStart`, `turnEnd`, `dispatchStart`, `dispatchEnd`, `iterationStart`, `iterationEnd`, `turnGateOpen`, `turnGateClosed`, `toolExecutionStart`, `toolExecutionEnd`, `log`, `error`. Telemetry. The agent behaves identically whether anyone listens or not.
* Separate emitters. A throwing observability listener cannot block a functional emission. Product code cannot accidentally depend on telemetry being wired.
* Functional events and dispatch-scoped observability events originate in [`DispatchRunner.dispatch`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/classes/DispatchRunner#dispatch) and forward up when dispatch is sourced from a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext). In the `raw:` path nothing bubbles — subscribe at the dispatch runner.
* Pipeline and tool errors emit on observability as `error`. `run()` does not throw them. No fallback — if you don't subscribe, you don't see them.
* Abort is not an error. The pipeline short-circuits silently. `turnEnd` and `dispatchEnd` still fire; no `error` event.
* Common mistake: emit one `toolCall` per partial without sealing the final emit with `isComplete: true`. Subscribers wait forever.

[`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) has two event buses. They do two different jobs. Confuse them and observability code starts deciding agent behavior, or product code starts depending on telemetry being wired up.

## The rule

::: warning
If removing the listener would change the agent's behavior, it's functional. Otherwise, it's observability.
:::

Streaming a partial message to a UI is functional — the user sees nothing if no one listens. Recording an OpenTelemetry span is observability — the agent runs the same with or without it. Separate emitters enforce the split: a throwing observability listener cannot block a functional emission, and a missing functional listener does not silently drop telemetry.

## The functional bus

Three events: `message`, `thought`, `toolCall`. The executor calls `helpers.reportMessage(id, delta)` / `reportThought(...)` / `reportToolCall(id, partial)` as chunks arrive; the helpers normalize each call into a streaming payload and the bus emits it. If nothing is subscribed, the agent still runs — the user just sees nothing.

`message` and `thought` carry [`TurnStreamableContent`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnStreamableContent). One `id` per stream. `full` is the accumulated body to date. `isComplete: true` seals the stream. UIs append `aDelta`; persistence layers snapshot `full`.

::: danger The field is named `aDelta` — not `delta` — on purpose
The leading `a` stands for **additive**. The name is the contract: this is an *append chunk*, not a general-purpose diff.

* **The type has no diff payload, no patch operator, no retraction channel.** `TurnStreamableContent` is `{ id, full, aDelta, isComplete, … }` — there is nowhere to express a removal, a replacement, or a reorder. By design.
* **The stock helpers enforce additive accumulation.** `reportMessage` / `reportThought` compute `full = full + aDelta` and throw on any chunk after `isComplete: true`. Bypass the helpers (call `ctx.emitMessage` directly) and you maintain that contract yourself — subscribers assume it.
* **Persist `full`. Render with `aDelta`.** Deltas are for UIs that already painted the previous chunk; storage and audit need the canonical body.
* **Why the shape.** LLMs are token generators. They emit forward and do not retract — there is no decoder operation that means "un-say the last sentence." A diff/patch/retraction channel would be machinery for a scenario the standard executor cannot produce. A non-LLM executor that *can* retract (a human typist, a programmatic editor) is your edge case to design around — emit a fresh stream with a new `id`.

If you find yourself wanting `aDelta` to behave like a generic delta, you are misreading the field. Read `full`.
:::

`toolCall` carries [`TurnToolCallContent`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnToolCallContent). The model's request and the eventual result share one envelope keyed by `id`. Arguments stream as partials; once the call settles, middleware writes the result back through the same `id` with `isComplete: true`. [`TurnToolCallContent.checksum`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnToolCallContent#property-checksum) is what [`DispatchContext.toolCallCount`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/DispatchContext#toolcallcount) counts and what [nonce-keyed adapter envelopes](./trust-tiers/envelopes) bind to.

::: tip Seal the stream
No further deltas with the same `id` after `isComplete: true`. Forget the seal and subscribers wait forever. The UI spinner is not "slow"; it is waiting for an `isComplete: true` that your early-return path never emitted.
:::

The functional bus is the *only* place these streams surface. To count messages or tool calls in telemetry, aggregate on the functional bus or count `toolExecutionEnd` / `iterationEnd` on observability.

## The observability bus

Telemetry. This bus is the flight recorder, not the steering wheel: turn lifecycle, dispatch lifecycle, gates, tool execution, structured logs, and errors. Every payload carries `turnId`. Dispatch-scoped events add `dispatchId` and `iteration`. Tool events add `callId` — `sha256` of `{ tool, args }` over the raw, pre-validation arguments. Full payload shapes are in the API reference ([`TurnGateClosedEvent`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnGateClosedEvent) is canonical; the rest follow the same convention).

* **`turnStart` / `turnEnd`** — the outer envelope. `turnEnd` carries `durationMs` and fires even on abort.
* **`dispatchStart` / `dispatchEnd`** — one dispatch inside the turn. `dispatchEnd.status` is `'ack' | 'nack' | 'aborted'` — the signal the executor loop has stopped.
* **`iterationStart` / `iterationEnd`** — one trip through the executor seam. Separates model latency from middleware latency.
* **`turnGateOpen` / `turnGateClosed`** — a [`TurnGate`](https://adk.nht.io/api/@nhtio/adk/common/interfaces/TurnGate) opened and settled. `open` carries the live gate (render your approval UI); `closed` carries the [`TurnGateClosedEvent.result`](https://adk.nht.io/api/@nhtio/adk/turn_runner/interfaces/TurnGateClosedEvent#property-result). Join on `gateId` to measure operator response time.
* **`toolExecutionStart` / `toolExecutionEnd`** — wraps the tool handler. The observability `callId` is `sha256({ tool, args })` — the same value as the functional-bus `toolCall.checksum` and `ToolCall.checksum`. Join the two buses on it. *Not* on `toolCall.id`: that is the stream id ([`ToolCall.id`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolCall#property-id)), never the cross-bus key. The hash collides by design for identical `(tool, args)` (that is what `toolCallCount` counts), so it names *what* was called, not *which instance* — separate instances by the `DateTime` fields (`startedAt` / `endedAt` here, `createdAt` / `updatedAt` on the functional event).
* **`log`** — structured executor diagnostics. Level, kind, message, optional payload.
* **`error`** — a [`BaseException`](https://adk.nht.io/api/@nhtio/adk/factories/classes/BaseException) wrapping whatever a pipeline stage threw.

::: warning Abort is not an error
When the turn aborts (or a stage throws `AbortError`), the pipeline short-circuits silently. `turnEnd` and `dispatchEnd` still fire. No `error` event. If you alert on `error`, abort traffic is invisible — watch `dispatchEnd.status` instead.
:::

Pipeline errors (input middleware, dispatch, output middleware) and downstream tool errors land on observability as `error`. `run()` does not throw them. No fallback: if you do not subscribe, they are gone.

## Forwarding from dispatch

Functional events and dispatch-scoped observability events originate in [`DispatchRunner.dispatch`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/classes/DispatchRunner#dispatch). The runner forwards them to the [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) buses when dispatch is sourced from a [`TurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext). In the `raw:` path nothing bubbles — subscribe at the [`DispatchRunner`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/classes/DispatchRunner) level instead.

## What to do with what

| Use case | Bus |
| --- | --- |
| Render streaming model output | functional (`message`, `thought`) |
| Surface tool calls and results to the user | functional (`toolCall`) |
| OpenTelemetry spans / traces | observability (`turnStart`, `dispatchStart`, …) |
| Metrics — turn count, latency, tool error rate | observability (`turnEnd`, `toolExecutionEnd`) |
| Structured executor diagnostics | observability (`log`) |
| Centralized error reporting (Sentry / Bugsnag) | observability (`error`) |
| Render UI for a human-approval gate | observability (`turnGateOpen`, `turnGateClosed`) |

## Where to go next

* [Turn Runner](./turn-runner) — the runner that owns both buses.
* [LLM Dispatch](./llm-dispatch) — where forwarded events originate.
* [Gates](./gates) — the source of `turnGateOpen` / `turnGateClosed`.
* [Failure](./failure) — the exception taxonomy `error` surfaces.
