Aviary
Guides

Human-in-the-loop & Durability

Action tools pause for a human decision — and under the durable runner that pause is a real durable signal, so the run suspends in the state store and resumes on approval, surviving restarts.

Some tools are safe to run on the model's say-so; some are not. A read tool auto-executes. An action tool never does — the loop pauses for an explicit approve/reject. How that pause is implemented is the difference between the inline runner and the durable runner, but the wire protocol your frontend speaks is identical either way.


The approval flow

Declare a tool kind: 'action' and it becomes gated:

@AiTool({
  name: 'purgeCache',
  kind: 'action', // never auto-executes — the run pauses here
  description: 'Purge a cache key.',
  input: z.object({ key: z.string() }),
})
export class PurgeCacheTool implements ToolHandler<{ key: string }> {
  async execute(input: { key: string }) {
    await this.cache.purge(input.key);
    return { purged: input.key };
  }
}

When the model calls purgeCache, the loop stops before execute and waits. A human resolves it through the REST endpoints:

# approve
curl -X POST http://localhost:3000/agent/tool-call/approve \
  -H 'content-type: application/json' \
  -H 'x-actor-id: u1' -H 'x-actor-role: ADMIN' \
  -d '{ "toolCallId": "..." }'

# reject (with an optional reason the model sees)
curl -X POST http://localhost:3000/agent/tool-call/reject \
  -H 'content-type: application/json' \
  -H 'x-actor-id: u1' -H 'x-actor-role: ADMIN' \
  -d '{ "toolCallId": "...", "reason": "not now" }'

On approval the tool executes and the turn continues; on rejection the loop resumes with the rejection (and any reason) fed back to the model, which adapts instead of running the action.

A decision carries only the toolCallId: the server derives the exact run awaiting it (the tool call's thread's active stream), so no run id is sent or trusted. That is also what lets a delegated sub-agent's action tool be approved — a sub-agent streams into the top-level run the human is watching, and the same toolCallId resolves to the sub-agent's own child run.

Both endpoints are ownership-scoped: the acting actor must own the tool call's run, or the request gets a 403 (404 if the tool call doesn't exist at all).

The frontend wraps this

useAgentChat surfaces pending approvals and exposes approve/reject callbacks, so a UI renders an "Approve?" prompt inline in the conversation. See Frontend.


Two runners, one protocol

The same pause is backed two ways, chosen by the durable flag on AgentModule.forRoot:

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
Requiresnothing extraAgentDurableModule + a configured DurableModule
Wire protocolidenticalidentical

Because the protocol is identical, you can develop against the inline runner and flip to durable in production without touching your frontend or your tools.


Under the durable runner

With durable: true, each agent turn runs as a @dudousxd/nestjs-durable workflow (the agent.run workflow). Every model call and every tool call is a checkpointed step — so a crash mid-turn replays from cache rather than re-charging the model or re-running a side effect.

AgentModule.forRoot({
  model: myModelProvider,
  store: myAgentStore,
  actorResolver: new HeaderActorResolver(),
  durable: true, // each turn is the durable `agent.run` workflow
  // ...
});

durable: true on its own configures the agent to expect the durable runner; the runner itself lives in AgentDurableModule, which you import alongside a configured DurableModule. The agentDurable(options) helper from @dudousxd/nestjs-agent/durable collapses those two imports into one — it wires AgentModule.forRoot({ ...options, durable: true }) and AgentDurableModule together, so you spread it into imports:

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 {}

The longhand — AgentModule.forRoot({ durable: true }) plus a separate AgentDurableModule import — still works; agentDurable is just the one-import shortcut.

The human-in-the-loop pause becomes a durable signal: on the approval boundary the run suspends into the state store (via the durable waitForSignal primitive) instead of holding a connection open. An approve/reject POST delivers the signal and the run resumes — minutes or restarts later — replay-safe.

Streaming and durability compose, they don't fight

Live tokens don't go through the durable checkpoint path — they flow on a separate data plane (a TokenStreamSink). So you get resumable, replay-safe orchestration and token-by-token streaming at once, rather than trading one for the other.


Dispatched steps: the run isn't pinned to a pod

Under durable: true, the turn's two long steps — the model call and each tool execution — dispatch as routed durable steps (AgentRunSteps.llm / AgentRunSteps.tool) instead of running in-process. This is the default the moment durable: true is set; opt out with dispatchedSteps: false to keep them as in-process ctx.localSteps.

agentDurable({
  model: myModelProvider,
  store: myAgentStore,
  actorResolver: new HeaderActorResolver(),
  // dispatchedSteps: false, // opt out — keep the llm/tool steps in-process
});

AgentRunSteps.llm and .tool are always registered as worker groups by AgentDurableModule regardless of this flag — the worker group is never left unserved — so the flag only decides whether the workflow dispatches to them or runs the equivalent step in-process with ctx.localStep. Each dispatched step re-resolves its own dependencies from whichever worker picks it up (its own DI, via AGENT_DEPS_FACTORY.forAgent), so a run can leave its originating pod for the model call and come back to a different one for a tool execution; the retry policy differs by step too — the llm step retries (@Step({ retries: 3 })), the tool step doesn't, since tool idempotency is your domain, not the runtime's.

Multi-pod fleets need a cross-process token sink

A dispatched step can run on any worker in the fleet — not necessarily the pod holding the SSE connection. Wire a cross-process TokenStreamSink (e.g. RedisTokenStreamSink) so streamed tokens reach the right connection regardless of which pod executes the step. A boot warning fires when dispatchedSteps is effectively on (the default under durable: true) with the default in-process sink.

A suspend is never mispersisted as a tool failure

The durable runner's suspend/continue-as-new signals — thrown when a waitForSignal (an action tool's approval pause, among others) fires — are recognized by the durable runtime's marker-based isWorkflowControlFlowSignal before they ever reach the loop's tool-catch handling. Earlier releases mis-detected this by instanceof, which broke under the BullMQ thin worker (a different Suspend class) and corrupted the run's history on resume; the marker check fixed that across every classification site (the workflow's own catch, the loop's isControlFlowError hook, and the runner's start-suspend swallow). This holds whether the suspending step ran in-process or dispatched, so you never need to special-case a suspend in your own tool code.


Approving from a console, not just chat

Approve/reject don't have to originate from the chat UI watching the run. A separate surface — a governance console, an ops dashboard — can bind AGENT_APPROVAL_PORT (AgentApprovalPort), an SPI the agent runtime implements and provides, routing through the exact same decision path the chat endpoints use (the durable signal, or the inline in-memory resolution) without re-running the ownership check the chat endpoints do — the console's own guards are expected to front it instead.

interface AgentApprovalPort {
  approve(toolCallId: string, opts?: { executedByRef?: string }): Promise<void>;
  reject(toolCallId: string, opts?: { executedByRef?: string; reason?: string }): Promise<void>;
}
Two decision sources — chat UI and the dashboard's approvals inbox — converge on the same signal that resumes a suspended tool callChat UIthread's own actorapprove / rejectownership-checkedDashboard inboxAGENT_APPROVAL_PORTopts.executedByRefconsole-guardedSignaltool:<runId>:<callId>Turn resumesAgentRunner.signaldecider = executedByRef?? input.actor.idtool executes, loop continuessignalToolCallsignalToolCallAgentRunner.signal
Chat and the dashboard's approvals inbox are two different callers of the same signal — AgentRunner.signal resolves the suspended tool call either way, and the decider on the resulting Decision is executedByRef when the console supplied one, the run's own actor otherwise.

executedByRef records who actually decided. It lands on the tool call's persisted decision (decision.executedByRef ?? the run's own actor) on both the executed and the rejected path, so a console operator's approval is attributable even though the run's own actor never touched the request. @dudousxd/nestjs-agent-dashboard's approvals inbox stamps this from the live console request via an approvalActorRef dashboard option that — as of @dudousxd/nestjs-agent 0.7.0 — defaults to the same ActorResolver the agent module is already configured with, so a console whose auth matches chat auth needs no extra wiring; override approvalActorRef only when the console's request shape differs. See Cost & Governance for the approvals inbox itself (pendingApprovals, the dashboard section, the 501 a port-less console gets).


Resuming a stream

Durability also covers the transport. A dropped SSE connection reconnects and replays the buffered tokens for the in-flight run:

# resume an in-flight run's stream
curl -N http://localhost:3000/agent/chat/$RUN_ID/stream \
  -H 'x-actor-id: u1' -H 'x-actor-role: ADMIN'

A run can also be cancelled:

curl -X POST http://localhost:3000/agent/chat/$RUN_ID/cancel \
  -H 'content-type: application/json' \
  -H 'x-actor-id: u1' -H 'x-actor-role: ADMIN'

Cancel is ownership-scoped, same as approve/reject: the acting actor must own the run, or the request gets a 403 (404 if the run doesn't exist).

A failed durable run surfaces the same way as an inline one

Durable runs emit event: error on a failure (e.g. quota exceeded) exactly like the inline runner — the HTTP stream ends with the structured error frame instead of hanging. Durability changes how the run is checkpointed, not how a failure is reported to the client.


Observability

Every stage of a run is announced on Node's diagnostics_channel under aviary:agent:* (run.started, message, tool-call, delegated, quota.exceeded, run.failed, run.finished). The telescope extension consumes them for the "Agent" tab; under the durable runner, runs also appear in the durable "Workflows" tab for free. See Cost & Governance.


  • Tools — declaring read vs action tools
  • Multi-agent — a delegated sub-agent is a durable child run
  • Frontend — rendering the approve/reject prompt in the UI
  • Cost & Governance — the diagnostics events and run history

On this page