Aviary
Concepts

Runners

One AgentRunner SPI, two implementations — inline runs in-process by default, durable is an opt-in that checkpoints every step and turns HITL approval into a real suspend.

The agent loop doesn't know or care how its step and awaitApproval hooks are implemented — that's the whole point of the AgentRunner SPI. Two implementations exist, and switching between them never touches your tools, your frontend, or the wire protocol.

interface AgentRunner {
  start(input: AgentRunInput): Promise<{ runId: string }>;
  signal(runId: string, toolCallId: string, decision: Decision): Promise<void>;
  cancel(runId: string): Promise<void>;
}

start enqueues and returns immediately with the runId — the actual tokens flow separately over the TokenStreamSink data plane, not through this call. See Architecture for how the control and data planes split.

Inline (default) vs. durable (opt-in)

Inline runner (default)Durable runner (durable: true)
The pausean in-process runner holds the turn opena real durable suspend — the run is checkpointed to the state store
Surviving a restartno — the pending turn is lostyes — the run resumes from cache on approval
Where the turn executesthe pod that received the request, start to finishwhichever worker picks up the agent.run workflow — and, by default, the model call and each tool call can each land on a different worker again (see below)
Requiresnothing extra@dudousxd/nestjs-durable + AgentDurableModule + a configured DurableModule
Wire protocolidenticalidentical

AgentModule.forRoot binds InlineAgentRunner to the AGENT_RUNNER token unless durable: true is set — inline is the real, zero-dependency default, not a fallback.

When to pick which

  • Inline for local development, tests (pairs naturally with @dudousxd/nestjs-agent-testing's fake model and in-memory store), and any deployment where a lost in-flight turn on a crash is acceptable.
  • Durable once you need a turn to survive a process restart mid-approval, want every model and tool call individually checkpointed (so a crash replays from cache instead of re-charging the model or re-running a side effect), or already run @dudousxd/nestjs-durable elsewhere in the app.

Because the AgentRunner SPI is the only seam, you can develop against inline and flip to durable in production by changing configuration, not code.

Dispatched steps: the durable runner's default posture

Under durable: true, the workflow's bookkeeping (persisting a message, bumping quota, stream markers) checkpoints as ctx.localStep — pinned to whichever worker is running the workflow. But the turn's two genuinely long operations — the model call and each tool execution — are, by default, dispatched as separately-routed durable steps instead (AgentRunSteps.llm / AgentRunSteps.tool), so a turn isn't pinned to one pod for however long the model takes to respond or a tool takes to run. Set dispatchedSteps: false (requires durable: true) to opt back into the pre-dispatch behavior and keep those two steps as ctx.localSteps as well.

Both worker groups are always registered by AgentDurableModule regardless of this flag — only whether the workflow actually routes to them is controlled by dispatchedSteps — so a fleet never ends up with an unserved worker group after a config change either way.

Multi-pod fleets need a cross-process sink

Because a dispatched step can execute on any worker, the pod that streams tokens for llm may not be the pod holding the client's SSE connection. Wire a cross-process TokenStreamSink (e.g. RedisTokenStreamSink from -transport-redis) for any multi-pod deployment under durable: true — dispatched or not, since the run itself is already not pinned to the requesting pod. A boot warning fires when dispatching is effectively on with the default in-process sink still bound. See Architecture for the full control/data-plane picture.

The one-import shortcut

The long form wires durable: true on AgentModule.forRoot and imports AgentDurableModule separately. agentDurable(options) collapses both into one:

import { DurableModule } from '@dudousxd/nestjs-durable';
import { agentDurable } from '@dudousxd/nestjs-agent/durable';

@Module({
  imports: [
    DurableModule.forRoot({ /* store, transport, … */ }),
    ...agentDurable({
      model: myModelProvider,
      store: myAgentStore,
      actorResolver: new HeaderActorResolver(),
      // ...same options as AgentModule.forRoot, minus `durable`
    }),
  ],
})
export class AppModule {}

agentDurable spreads into imports because it wires AgentModule.forRoot({ ...options, durable: true }) and AgentDurableModule together — it isn't a module itself.

durable: true alone isn't enough

Setting durable: true on AgentModule.forRoot without importing AgentDurableModule throws at boot with a clear message rather than failing silently or falling back to inline — a forgotten import is a configuration error, not a degraded mode.

Full details

The mechanics of the durable suspend itself — the agent.run workflow, ctx.waitForSignal, and resuming a dropped stream — are in Human-in-the-loop & Durability; the durable runtime it depends on is documented in full at @dudousxd/nestjs-durable.

Where to go next

On this page