---
url: 'https://adk.nht.io/batteries/skills/middleware.md'
description: >-
  Four pipeline hooks keep initialized skills projected onto fresh contexts,
  reconcile mid-dispatch changes, and strip injected state.
---

# Middleware and placement

`SkillMiddlewareSet` contains `turnInput`, `turnOutput`, `dispatchInput`, and `dispatchOutput`. Place `turnInput` early, `dispatchInput` before thrift/budget middleware, `dispatchOutput` after execution before persistence, and `turnOutput` at the head of the turn-output pipeline so it strips the projection before any downstream output middleware can persist or observe it. The set is always present; scripts and auto-refresh change behavior, not shape.

::: tip This is a featured battery
Projection is middleware because the tool registry is rebuilt every turn. Forgetting the projection hook means forgetting the lifecycle.
:::

Wire the manager, skip the projection hook, and you get a battery that appears to work exactly once. It loads cleanly, answers the first turn, and vanishes on the second. ADK rebuilds the tool registry from scratch on every turn, and nothing about activation survives that boundary on its own. Projection is the mechanism by which a loaded skill continues to exist.

The distinction matters. **Initialized** is manager state: which skills survive across turns in memory. **Projected** is per-context: which of those are actually hydrated into this turn's registry and callable right now. The middleware converts the first into the second, every turn.

That lifecycle spans four stages because each does a different job. A fresh turn needs initialized skills hydrated onto its empty registry. A dispatch inside that turn might load or unload a skill mid-flight, requiring integrity repair before execution proceeds. A third stage observes what changed, and the final stage strips everything the battery injected so nothing leaks into downstream persistence. That strip is why unload works: projected state is ephemeral by design.

The set is always all four, regardless of configuration. A manager without scripts or auto-refresh still returns four hooks; the inactive ones call `next()` and return. Configuration changes behaviour, never pipeline shape, so turning on a feature never forces you to rewrite your wiring. The manager hands that set back to you rather than installing it itself because ordering against your own middleware belongs to you — and getting it wrong is completely silent.

Use the manager's stable middleware set:

```ts
const manager = await createSkillManager({ sources: [source], gate })
const { turnInput, turnOutput, dispatchInput, dispatchOutput } = manager.middleware
// Place these in the corresponding ADK pipelines.
```

## Four responsibilities

| Hook | Responsibility | Placement |
|---|---|---|
| `turnInput` | Hydrate initialized skill tools and retrievables onto the fresh turn context; with `autoRefresh`, refreshes catalog only. | Early, before anything reads `ctx.tools` or budgets context. |
| `dispatchInput` | Remove unloaded skill-owned tools and reassert current projections after a mid-turn load. It does not prune generic artifact readers. | Before thrift/budget middleware. |
| `dispatchOutput` | Reconcile and observe after execution. It is not a commit point. | After executor, before result persistence. |
| `turnOutput` | Strip injected retrievables and skill-owned tools at the head of turn-output, before any downstream output middleware sees them — so the skill body never reaches downstream persistence or observation. | Head of turn-output. |

Standalone factories also exist: `skillsTurnInputMiddleware`, `skillsTurnOutputMiddleware`, `skillsDispatchInputMiddleware`, `skillsDispatchOutputMiddleware`, and `createSkillMiddlewareSet`.

## Ordering facts that matter

The turn registry exists before the turn input pipeline: the runner constructs `ToolRegistry`, then `TurnContext`, then awaits input middleware. Projection is therefore sound. The dispatch input pipeline is handed to `DispatchRunner` as its `turnInputPipeline`; the confusing parameter name is not permission to wire the hooks backwards.

A turn-level projection failure calls `TurnContext.abort()` because turn context has no `ack()`/`nack()`. A dispatch integrity failure calls `DispatchContext.nack()`. Middleware is skipped once a context is aborted, so cleanup cannot live only in middleware; `manager.dispose()` is also responsible for workspace teardown and dropping projections.

Settlement is immediate. A lifecycle handler mutates manager state and the registry before returning. A newly loaded tool is not advertised in the wire payload already being built, but it can resolve later in the same iteration. An unloaded tool is unregistered immediately; a later call fails resolution. `dispatchOutput` observes this; it does not commit it.

`turnOutput` strips before its own `next()`. The turn's answer is already persisted at dispatch; the strip runs at the head of the turn-output pipeline so every downstream output middleware — a consumer's observation or secondary persistence — sees a context the skill body and tools have already left. That downstream omission is the guarantee, not a gap: it is why "the body never leaves." Moving the strip below `next()` would expose the projection to downstream persistence and reintroduce the leak. It is also best effort: failed or aborted turns may return before output middleware runs. The context is discarded, and the next turn rehydrates from manager state. Workspace lifetime follows the skill, not the turn.

With an empty initialized set, input/output dispatch hooks short-circuit; `turnOutput` still strips residue from a skill unloaded mid-turn. `autoRefresh` never swaps a loaded version. Explicit `refresh_skills` performs the version replacement.
