Aviary

Getting Started

Stand up a governed AI agent in a NestJS app — install, declare a tool, register the module, and stream your first turn.

The fastest path to a working agent is four steps: install the packages, declare a tool as an ordinary injectable, register the module, and hit the SSE chat endpoint. The agent is the mechanism; the domain it governs — which model, which store, which roles — is policy you supply.


Prerequisites

  • Node.js 20+
  • NestJS 10+ (both v10 and v11 are supported)
  • TypeScript 5+ with experimentalDecorators and emitDecoratorMetadata enabled
  • A model provider — anything implementing the ModelProvider SPI. The zero-boilerplate path is aiSdkModel(...) from @dudousxd/nestjs-agent-ai-sdk, which adapts any Vercel AI SDK v7 LanguageModel so you write no provider code; e.g. aiSdkModel(anthropic('claude-sonnet-4-6')). Any other ModelProvider works too, and for offline tests there's a deterministic fake in @dudousxd/nestjs-agent-testing.

Step 1 — Install

pnpm add @dudousxd/nestjs-agent @dudousxd/nestjs-agent-core
# persistence + (optional) durable runner:
pnpm add @dudousxd/nestjs-agent-store-mikro-orm @dudousxd/nestjs-durable

The -core package holds the framework-agnostic SPIs and the agent loop; @dudousxd/nestjs-agent is the NestJS module. Add a store adapter (MikroORM or Drizzle) for persistence, and @dudousxd/nestjs-durable if you want each turn to run as a durable workflow.


Step 2 — Declare a tool

A tool is a regular NestJS injectable decorated with @AiTool. Its input is a Zod schema, and its kind decides execution: a read tool auto-executes; an action tool never does — the run pauses for a human decision (see Human-in-the-loop).

// src/tools/get-weather.tool.ts
import { AiTool, type ToolHandler, type AiToolCtx } from '@dudousxd/nestjs-agent';
import { z } from 'zod';

@AiTool({
  name: 'getWeather',
  kind: 'read', // 'read' auto-executes; 'action' requires approval
  description: 'Current weather for a city.',
  input: z.object({ city: z.string() }),
})
export class GetWeatherTool implements ToolHandler<{ city: string }> {
  async execute(input: { city: string }, ctx: AiToolCtx) {
    return { tempC: 21, summary: 'partly cloudy' };
  }
}

Tools are injectables

Because a tool is an ordinary provider, it can inject anything else in your app — a repository, an HTTP client, a queue. The agent discovers @AiTool providers automatically; you just list them in your module.


Step 3 — Register the module

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AgentModule, HeaderActorResolver } from '@dudousxd/nestjs-agent';
import { aiSdkModel } from '@dudousxd/nestjs-agent-ai-sdk';
import { anthropic } from '@ai-sdk/anthropic';
import { GetWeatherTool } from './tools/get-weather.tool.js';

@Module({
  imports: [
    AgentModule.forRoot({
      model: aiSdkModel(anthropic('claude-sonnet-4-6')), // any ModelProvider; this is the zero-boilerplate path
      // store: myAgentStore,          // optional — omit it and the store resolves from an imported store module
      defaultRoles: ['ADMIN'],         // roles a tool requires when its own `roles` is omitted
      actorResolver: new HeaderActorResolver(), // who's calling — see below
      // path: 'agent',                // route prefix (default 'agent')
      // durable: true,                // run each turn as the durable `agent.run` workflow
      // dispatchedSteps: false,       // under durable:true this defaults to true — opt out to keep steps in-process
      defaultAgent: {
        systemPrompt: 'You are a helpful ops assistant.',
      },
    }),
  ],
  providers: [GetWeatherTool],
})
export class AppModule {}

durable: true dispatches the turn across your worker fleet by default

With durable: true, the turn's model call and tool executions are — by default — routed durable steps (AgentRunSteps.llm / AgentRunSteps.tool), so the run isn't pinned to the pod that started it; set dispatchedSteps: false to keep them as in-process steps instead. Either way, a multi-pod deployment under durable: true needs a cross-process TokenStreamSink (e.g. RedisTokenStreamSink from @dudousxd/nestjs-agent-transport-redis) so the pod that ends up executing the step can still reach whichever pod holds the client's SSE connection — a boot warning fires if it's missing. See Architecture and Runners.

There is no insecure default identity

The agent never invents a caller. Every request's actor — { id, roles?, tenantRef? } — comes from an ActorResolver you configure. actorResolver is a required field on AgentModule.forRoot — there's no default to omit it in favor of. The shipped HeaderActorResolver reads x-actor-id / x-actor-role / x-tenant-ref and is only safe behind a trusted gateway that strips and re-sets those headers. See Identity & Authorization.


Step 4 — Start a turn

The module mounts SSE + REST endpoints under /agent (configurable via path). Start a turn by POSTing to /agent/chat; tokens stream back as Server-Sent Events.

curl -N http://localhost:3000/agent/chat \
  -H 'content-type: application/json' \
  -H 'x-actor-id: u1' -H 'x-actor-role: ADMIN' \
  -d '{ "message": "What is the weather in Lisbon?" }'
event: meta
data: {"runId":"...","threadId":"..."}

data: {"delta":"It's "}
data: {"delta":"partly cloudy "}
data: {"delta":"and 21°C in Lisbon."}

event: done
data: {"finishReason":"stop"}

The model calls getWeather (a read tool, so it auto-executes), then streams its answer. In a browser, the -react frontend wraps all of this — threads, the agent catalog, quota, cancel, and approvals — in a single useAgentChat hook.


The endpoints

Method & pathPurpose
POST /agent/chatStart a turn; streams tokens as SSE
GET /agent/chat/:runId/streamResume an in-flight run's stream
POST /agent/chat/:runId/cancelCancel a run
POST /agent/tool-call/approve · /rejectHuman-in-the-loop decision for an action tool (ownership-scoped: 403 on someone else's tool call)
GET /agent/threads · /:id · DELETE /:id · POST /:id/fork-from/:messageIdThread history (:id routes are ownership-scoped: 403/404 outside your own threads)
GET /agent/agents · GET /agent/quota/todayAgent picker catalog & quota

Try it offline first

@dudousxd/nestjs-agent-testing ships an in-memory store and a deterministic fake model, so you can exercise the whole loop — auto-executing reads, a suspending action tool, multi-agent delegation — with no API key or Redis. The repo's examples/agent-demo is a runnable proof:

pnpm --filter agent-demo demo   # the offline scripted proof
pnpm --filter agent-demo start  # the full NestJS app + console at /ai-gateway

Next steps

On this page