Skip to content
4 min read · 724 words

Middleware and placement

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

HookResponsibilityPlacement
turnInputHydrate initialized skill tools and retrievables onto the fresh turn context; with autoRefresh, refreshes catalog only.Early, before anything reads ctx.tools or budgets context.
dispatchInputRemove unloaded skill-owned tools and reassert current projections after a mid-turn load. It does not prune generic artifact readers.Before thrift/budget middleware.
dispatchOutputReconcile and observe after execution. It is not a commit point.After executor, before result persistence.
turnOutputStrip 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.