Architecture
The library is mechanism — the loop, tool registry, RBAC, quota, cost, audit, HITL, streaming; your domain is policy you supply through a fixed set of SPIs.
@dudousxd/nestjs-agent draws one hard line: mechanism lives in the library, policy lives in
your app. The agentic loop, the tool registry, per-tool RBAC, daily quota, cost accounting, audit,
human approval, and resumable streaming are all mechanism — you never rewrite them. Which model,
which store, which roles, which tenant column — that's policy, and it's supplied entirely through a
small set of SPIs (service provider interfaces). Swap an SPI's implementation and nothing else in
the loop changes.
The SPIs are the seams
Every place the library needs something app-specific, it asks for an interface instead of assuming an answer:
| SPI | What it abstracts | Shipped implementation(s) |
|---|---|---|
ModelProvider | Calling the LLM for one turn | aiSdkModel(model) (-ai-sdk, any Vercel AI SDK v7 LanguageModel); FakeModelProvider (-testing) |
AgentStore | Threads, messages, tool calls (the write path) | MikroORM or Drizzle (-store-mikro-orm, -store-drizzle); InMemoryAgentStore (-core, the module's fallback when no store is configured) |
AgentGovernanceQueries | Spend/usage read-model (the analytics path) | Same MikroORM/Drizzle store packages; InMemoryGovernanceQueries (-testing) |
ActorResolver | Who's calling | AnonymousActorResolver (-nestjs, the default — one identity per browser); requestUserActorResolver() (req.user); HeaderActorResolver (gateway-only) |
RolesPolicy | May this actor call this tool | DefaultRolesPolicy (role set-intersection, empty list open, -core); ClosedRolesPolicy (empty list denies — bound by emptyRoles: 'deny'); AuthzRolesPolicy (-authz, delegates to a nestjs-authz Gate) |
ApprovalPolicy | Whether an action call needs approval, who decides, and its expiry | DefaultApprovalPolicy (-core: every action, decided by the requester, no expiry); yours via approvalPolicy |
ActionProposalStore (+ worker / outcome capabilities) | Independent action proposals: decisions, fenced execution leases, outcome delivery — only with actionApprovalMode: 'independent' | InMemoryActionProposalStore (-core, one process); the MikroORM and Drizzle stores |
TokenStreamSink | Live token transport (the data plane) | InProcessTokenStreamSink (-nestjs, the module default); for multi-pod fleets — which a durable fleet is, since its steps dispatch across workers — RedisTokenStreamSink (-transport-redis, or @dudousxd/nestjs-agent/sink-redis) or the polling SQL sinks DrizzleTokenStreamSink / MikroOrmTokenStreamSink, via forRoot({ sink }) |
QuotaProvider | The actor's budget windows, and whether one blocks sends | LedgerQuotaProvider (-nestjs, ledger-backed; ceilings via quota: { limits }); your own via quota: provider |
AgentRunner | Executing a turn | InlineAgentRunner (-nestjs, the module default); the durable runner from AgentDurableModule |
AgentStore and AgentGovernanceQueries are separate interfaces on purpose — one owns writes
(threads/messages/tool calls), the other owns read/analytics (spend, trends, activity) — but a
single store adapter binds both, so you configure persistence once. See
Persistence and Cost & Governance.
Every SPI has a working default — know which are open
AgentModule.forRoot({ model }) runs with defaults for everything else (InMemoryAgentStore,
InProcessTokenStreamSink, InlineAgentRunner, LedgerQuotaProvider, DefaultRolesPolicy,
AnonymousActorResolver). Two of those are open by design: without actorResolver the endpoints are
public and every browser is its own anonymous actor (logged at boot), and DefaultRolesPolicy treats
a tool with no roles as callable by anyone. Set actorResolver (e.g. requestUserActorResolver())
to require login, and emptyRoles: 'deny' or defaultRoles to close unscoped tools. See
Identity & Authorization.
Control plane vs. data plane
The loop's checkpointed state (model calls, tool calls, approvals) and its live token stream travel on two different planes, and they don't fight each other:
- Control plane — the model turn and every tool call run as checkpointed steps under
whichever
AgentRunneris active. Under the inline runner that's just an awaited call. Under the durable runner, bookkeeping steps (persisting a message, bumping quota, stream start/finish markers) arectx.localSteps — checkpointed, but pinned to the workflow's own worker, since dispatching a few-millisecond DB write through a queue buys nothing. This is what makes anactiontool's approval pause safe to suspend either way. - Data plane — tokens the model streams mid-turn flow through the
TokenStreamSink(open/subscribe/close), independent of the control plane's checkpoints. A dropped SSE connection reconnects and replays the buffer; a durable suspend doesn't touch it at all.
This split is why streaming and durability compose instead of trading off — see The agent loop and Runners.
Dispatched steps: the turn's two long operations are routed, not pinned
The model call and each tool execution are the turn's two genuinely long-running operations — under
the durable runner they are dispatched as routed durable steps
(AgentRunSteps.llm / AgentRunSteps.tool), not run as ctx.localSteps on whichever pod happens to
be hosting the workflow:
AgentRunSteps.llm(@Step({ retries: 3 })) re-resolves the model, sink, and tool definitions from the serving worker's own DI and streams from wherever it actually runs — a live schema instance (a tool's Zod input) can't cross the wire, so the envelope carries only serializable data (LlmStepEnvelope) and the handler re-derives the tool set from the actor.AgentRunSteps.tool(no retries — tool idempotency is the app's own concern) rebuilds the toolctxhandler-side and applies the tool timeout there too.- Both worker groups are registered by
AgentDurableModuleunder every surface but'http', and every turn dispatches to them. There is no option that keeps the two long steps in the workflow body:ctx.stepis the one step primitive innestjs-durable, and this library follows it. - The bill lands on your tool handlers, so it is worth saying plainly: an
@AiToolhandler inherits no caller context in the serving worker, exactly as a@Stephandler does not. A handler that touches infrastructure needs@CreateRequestContext()/@EnsureRequestContext()on it. Without one it throws, the call is recordedfailed, the model is handed the error and the turn completes — and for anactiontool that means a human's approval spent on an action nobody performed.
The practical consequence: a dispatched turn is not pinned to the pod that started it. The
model call or a tool execution can land on any worker in the fleet, which is exactly why a
multi-pod deployment needs a cross-process TokenStreamSink — the worker that ends up running
AgentRunSteps.llm streams tokens through the sink, and that sink has to reach whichever pod is
still holding the client's SSE connection. InProcessTokenStreamSink (the module default) cannot
do this; wire RedisTokenStreamSink (-transport-redis) instead. A boot warning fires when
durable: true is on with the default in-process sink still bound.
A run journaled before dispatch finishes the way it started
Which names a turn writes — AgentRunSteps.llm/.tool or the in-process llm:<i>/tool:<callId> —
is read from the run's own journal via ctx.patched('agent:dispatched-steps'), never from
configuration: a fresh run records the marker and dispatches, and a run recorded by an older release
keeps the names its history holds. So the answer cannot differ between two pods of one deployment,
and an in-flight turn survives the upgrade.
Run reliability: every run's outcome is durably recorded
Run outcomes are recorded independently of thread/message history, on an optional AgentStore SPI
surface (recordRunStart / recordRunEnd / bumpRunRetries — absent on a store implementation
degrades to a graceful no-op, not an error):
- The loop records start and completed (with duration) as checkpointed steps; the durable workflow and the inline runner each record a failure with its error code/message on their own catch path.
- Both bundled store adapters (MikroORM, Drizzle) ship the recording as an
agent_runtable (autoSchema-managed), including apromptHash— the sha256 of the run's resolved, pre-RAG system prompt, so you can correlate an error-rate shift with a prompt change across runs. AgentGovernanceQueriesexposes the aggregated view (runMetrics,runsByAgent,runErrors,runTrend,recentRuns, plus a paginatedrunsPage) — see Governance for the read-model's shape and Cost & Governance for using it.
Observability: tracing a turn as spans
Every run also emits diagnostics spans — not just the point events on aviary:agent:* — so a
single turn can be reconstructed as a nested waterfall instead of a flat event list:
- Five span events:
llm.turn,tool.execution,retrieval,follow-ups,structured-output— each correlated bytraceId = runId, emitted from inside the checkpointed step body that does the real work (including fromAgentRunSteps.llm/.toolwhen a step is dispatched, so a span comes from whichever worker actually executed it). A replayed (cached) step never re-emits its span. - Payloads are metadata-only — model id, token counts, tool name/type, step index — never prompt or output text, matching the point events' redaction posture.
@dudousxd/nestjs-agent-telescoperenders the waterfall in Telescope's TRACES tab (requires a span-aware diagnostics bridge and aRecordInput.traceId-aware Telescope build); without those, the spans are emitted but simply unobserved — zero cost either way, since the phase envelopes are gated on subscriber presence.