Skip to content
5 min read · 1,021 words

Composition

Pipelines covers what the four pipelines are and which context each one writes to. This page is the next level down: the contract a single middleware signs, what happens around the one call to next(), and where to open a gate. The failure modes the runner attaches to that contract live on Throws and Abort.

The function shape

A middleware is a function. The runner hands it the context and a continuation called next. The function returns nothing — the context is the work surface. Turn-scoped middleware sees a TurnContext; dispatch-scoped middleware sees an DispatchContext.

next() is the hand-off to the rest of the pipeline. Call it to run downstream; await it to suspend until downstream has finished. One function body, both legs of the trip: pre-step before, post-step after.

Call await next() exactly once. Skip it and downstream never runs, but upstream unwinds normally — every upstream await next() resolves, every upstream post-step runs. The skip is detected (callout below). A second next() is a silent no-op; treat it as a bug. Calling without await races your pre-step against downstream work — if your middleware genuinely has no post-step, write return next(). Neither of those last two is detected. Write your middleware right.

Skipping next() emits an error; use ctx.abort() to refuse

A pipeline that does not run to completion is reported, not silent. When a middleware returns without calling next() and the turn was not aborted, the runner emits E_PIPELINE_SHORT_CIRCUITED on the observability bus, labelled with the seam (turn-input, turn-output, dispatch-input, dispatch-output).

That is the right behaviour for a bug. It is the wrong behaviour for an intentional refusal — a refusal is not an error, and should not light up error alerting. The channel for refusal is ctx.abort(reason). Once you call it, the runner stops invoking middleware bodies: the aborting middleware finishes its own body inline (so any cleanup you write between ctx.abort() and return runs — but if you return before await next(), there is no post-step, because a post-step is by definition code that runs after next() resolves), every middleware after it in the same pipeline is skipped, and the next major stage of the turn is skipped too. Upstream middlewares' post-steps still run normally on the unwind. turnEnd still fires and no error event is emitted. The operational signal depends on where the abort landed: if dispatch had already started, dispatchEnd.status === 'aborted'; if it had not (abort during turnInputPipeline), there is no dispatchEnd at all and the signal is turnEnd plus the absence of dispatchStart. The reason is available on ctx.abortSignal.reason from inside the pipeline but is not carried on dispatchEnd — capture it via observability from inside the abort source if you want to log why. Refusal becomes observable, intentional, and indistinguishable only from other intentional aborts — which is the point.

There is no good reason to bare-skip next(). If your middleware has nothing to do this turn, still call next() — doing nothing in your own body is not the same as stopping the pipeline. If you want the pipeline to stop, that is a decision, and the channel for it is ctx.abort(reason). A bare skip is always one of two things: the bug the detector reports, or the abort you should have spelled out. Reach for ctx.abort().

Sequencing

Strict array order

The pipelines are arrays. The runner invokes middleware in the order you wrote, every time. No DAG, no priority, no dependsOn. If B reads ctx.stash that A wrote, A goes first.

A dependency-resolving system gives you implicit ordering at the cost of "why did B run before A this time" being a real debugging question. An array gives you one ordering — the one you wrote, the same on every turn. Composition is a code-review concern, not a runtime mystery.

The cost is yours. If A and B both write the same ctx.stash key, last write wins. If B reads a key A never set, B sees undefined. The runner enforces array order and nothing more.

Where to open a gate

TL;DR

await ctx.waitFor(rawGate) blocks exactly the scope you put it in:

  • In turnInputPipeline / turnOutputPipelinethe turn pauses.
  • In dispatchInputPipeline / dispatchOutputPipelinethe dispatch iteration pauses.
  • Inside a tool handler → that tool-call resolution pauses.

Before next(): downstream pipeline stops. After next(): your post-step stops. Other turns running on the same runner keep moving. There is no magic background lane.

A middleware that needs to suspend calls await ctx.waitFor(rawGate). The runner opens a TurnGate and returns a promise that resolves when the gate settles. From the caller's perspective, runner.run() does not resolve until every gate on that turn has settled or the turn aborts.

Where in the pipeline you open it decides what blocks.

  • Before next() holds every downstream middleware in the same pipeline. Approval gates that must run before the prompt is packed open here — the model never sees the request until the gate resolves.
  • After next() holds only this middleware's post-step. Human review of model output opens here, in turnOutputPipeline — the model has already produced; the gate decides what reaches the caller.
  • Inside a tool handler pauses tool execution; the current iteration cannot complete until the handler returns. Tool-execution approval — "may this tool call run?" — opens here.

What does not pause: other turns. Each runner.run() is its own promise chain; a gate on turn A does nothing to turn B. Aborting a turn rejects every gate it owns with E_TURN_GATE_ABORTED; gates on other turns are unaffected.

Gates covers what gates are and are not, the canonical applications, durability, and settlement.

Where to go next

  • Throws — how throws are wrapped, why post-steps run on the error path, and the commit-vs-rollback pattern.
  • Abortctx.abort(reason), ctx.abortSignal, and classifying abort traffic from outside the runner.
  • Pipelines — the hub.
  • What each pipeline owns — canonical responsibilities.
  • stash — cross-middleware state.
  • Gates, Failure.