Aviary
Recipes

Inline → durable

Durable is the production default — dispatchedSteps routes every model/tool call as a checkpointed, worker-served step the moment durable is true. This recipe covers getting to durable: true, and the dispatchedSteps: false escape hatch for a simple in-process case.

durable: true is one dependency and one flag away from the default in-process runner, and once it's on, dispatchedSteps defaults to true with it — the turn's two long steps (the model call and each tool execution) are routed as durable steps (AgentRunSteps.llm / AgentRunSteps.tool) served by whichever worker picks them up, not pinned to the pod that started the run. That's the correct production posture: the llm step gets engine retry, and the run isn't stuck if its originating pod dies. Nothing about your tools, your store, or your frontend changes either way.


Before — inline (default)

// src/agents/ops.agent.ts
import { Agent } from '@dudousxd/nestjs-agent';

@Agent({ name: 'ops', systemPrompt: 'You are a helpful ops assistant.' })
export class OpsAgent {}
// src/app.module.ts
import { AgentModule, HeaderActorResolver } from '@dudousxd/nestjs-agent';
import { Module } from '@nestjs/common';
import { OpsAgent } from './agents/ops.agent.js';
import { myAgentStore } from './store.js';
import { myModelProvider } from './model.js';

@Module({
  imports: [
    AgentModule.forRoot({
      model: myModelProvider,
      store: myAgentStore,
      actorResolver: new HeaderActorResolver(),
    }),
  ],
  providers: [OpsAgent],
})
export class AppModule {}

Every turn runs in-process via InlineAgentRunner. It works, but a pod restart mid-turn loses the turn, and an action tool's approval pause is just a connection held open in memory.


After — durable, with dispatched steps ON by default

Add @dudousxd/nestjs-durable, then spread the one-import agentDurable(options) helper into imports alongside a configured DurableModule:

// src/app.module.ts
import { agentDurable } from '@dudousxd/nestjs-agent/durable';
import { HeaderActorResolver } from '@dudousxd/nestjs-agent';
import { DurableModule } from '@dudousxd/nestjs-durable';
import { Module } from '@nestjs/common';
import { OpsAgent } from './agents/ops.agent.js';
import { myStateStore, myTransport } from './durable-infra.js';
import { myAgentStore } from './store.js';
import { myModelProvider } from './model.js';

@Module({
  imports: [
    DurableModule.forRoot({ store: myStateStore, transport: myTransport }),
    ...agentDurable({
      model: myModelProvider,
      store: myAgentStore,
      actorResolver: new HeaderActorResolver(),
      // same AgentModuleOptions as before, minus `durable` — agentDurable sets it for you.
      // dispatchedSteps isn't set here either — it defaults to `true` under durable: true.
    }),
  ],
  providers: [OpsAgent],
})
export class AppModule {}

That's it — no separate flag to flip for dispatching. AgentRunSteps (the llm/tool worker groups) is always registered by AgentDurableModule regardless, so the routed steps are never left unserved.

agentDurable(options) is exactly the longhand below, collapsed to one import — from agent-durable.ts:

export function agentDurable(options: AgentModuleOptions): DynamicModule[] {
  return [AgentModule.forRoot({ ...options, durable: true }), AgentDurableModule.forRoot()];
}

Spelled out by hand, it's the same two imports:

import { AgentModule } from '@dudousxd/nestjs-agent';
import { AgentDurableModule } from '@dudousxd/nestjs-agent/durable';

@Module({
  imports: [
    DurableModule.forRoot({ store: myStateStore, transport: myTransport }),
    AgentModule.forRoot({ /* same options as above */ durable: true }),
    AgentDurableModule,
  ],
})
export class AppModule {}

Forgetting AgentDurableModule fails loudly, not silently

AgentModule.forRoot({ durable: true }) binds AGENT_RUNNER to whatever AgentDurableModule provides via AGENT_DURABLE_RUNNER — optional injection, so a missing import throws a clear error at boot ("requires importing AgentDurableModule from @dudousxd/nestjs-agent/durable") instead of an opaque unresolved-dependency crash. agentDurable(options) can't hit this: it always imports both together.


Opting out of dispatched steps

If you want durable's checkpointing and real HITL suspend but not the model/tool calls leaving their pod — a single-pod deployment, or a turn whose tools have external side effects you'd rather keep close to the caller for now — set dispatchedSteps: false. The turn's steps stay in-process ctx.localSteps instead of being routed through AgentRunSteps:

...agentDurable({
  model: myModelProvider,
  store: myAgentStore,
  actorResolver: new HeaderActorResolver(),
  dispatchedSteps: false,
}),

The cross-process sink requirement doesn't go away

Multi-pod fleets need a cross-process TokenStreamSink (e.g. @dudousxd/nestjs-agent-transport-redis's RedisTokenStreamSink) under durable: true regardless of dispatchedSteps — the turn already runs on whichever worker takes agent.run, which may not be the pod holding the SSE connection. A boot warning fires when durable: true is on with the default in-process sink, naming dispatchedSteps: false as the (incomplete) alternative — it isn't a substitute for wiring a real cross-process sink on more than one pod.

Setting dispatchedSteps: true explicitly without durable: true is a config error and throws at module build — there's no worker group to route to without the durable workflow underneath it.


Same wire protocol, same frontend

Nothing downstream of AgentModule needs to know which runner — or which dispatchedSteps setting — is active. The POST /agent/chat SSE stream, the POST /agent/tool-call/approve / /reject endpoints, and useAgentChat on the frontend are identical across all three: only how the pause, the checkpoint, and the model/tool execution are implemented differs.

Inline (default)Durable, dispatchedSteps: falseDurable, dispatched (default under durable: true)
The pause on an action toolan in-process runner holds the turn opena real durable suspend via waitForSignal, checkpointed to the state storesame as the middle column
Surviving a restartno — the pending turn is lostyes — resumes from cache on approvalyes
Where the model/tool call runsthe pod that started the runthe pod that started the run (localStep)whichever worker serves AgentRunSteps.llm/.tool — may not be the originating pod
Wire protocolidenticalidenticalidentical

So you can develop against the inline runner locally and get to the production default — durable: true with dispatched steps — without touching a single tool or frontend component.


  • RunnersAgentRunner as the SPI seam, and what "checkpointed step" means under the hood.
  • Configuration — the full dispatchedSteps contract and every other durable-related option.
  • Diagnostics events — the llm.turn/tool.execution trace spans, emitted from whichever worker actually executes a dispatched step.
  • Human-in-the-loop & Durability — the full approval flow, the durable signal, and resuming a dropped SSE stream.
  • Offline testing — exercising the inline runner (the default, and the one worth testing against) with zero infrastructure.

On this page