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: false | Durable, dispatched (default under durable: true) | |
|---|---|---|---|
The pause on an action tool | an in-process runner holds the turn open | a real durable suspend via waitForSignal, checkpointed to the state store | same as the middle column |
| Surviving a restart | no — the pending turn is lost | yes — resumes from cache on approval | yes |
| Where the model/tool call runs | the pod that started the run | the pod that started the run (localStep) | whichever worker serves AgentRunSteps.llm/.tool — may not be the originating pod |
| Wire protocol | identical | identical | identical |
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.
Related
- Runners —
AgentRunneras the SPI seam, and what "checkpointed step" means under the hood. - Configuration — the full
dispatchedStepscontract and every other durable-related option. - Diagnostics events — the
llm.turn/tool.executiontrace 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.
Custom ModelProvider
Implement the ModelProvider SPI by hand for a gateway or client aiSdkModel doesn't cover — stream text deltas to the sink, translate the endpoint's tool-call events, and never execute a tool yourself.
Functional tools with DI
Register a tool that needs constructor-injected dependencies without a class — provideAgentTool(factory, inject) resolves it through Nest's container and returns { spec, handler }, auto-discovered at boot exactly like an @AiTool class.