NestJS

09 Human in the Loop

Add approvals and reviewer decisions to NestJS Anvia modules.

In NestJS, place approval storage and decision handling in injectable services. Anvia hooks call those services, but Anvia does not provide your approval runtime.

1. Use Studio During Development

import { Injectable, OnModuleInit } from "@nestjs/common";
import { Studio } from "@anvia/studio";
import { SupportAgentService } from "./support-agent.service";

@Injectable()
export class StudioService implements OnModuleInit {
  constructor(private readonly supportAgent: SupportAgentService) {}

  onModuleInit() {
    new Studio([this.supportAgent.getAgent()]).start({ port: 4021 });
  }
}

Studio helps locally. Production reviewer permissions, records, and notifications belong to your Nest app.

2. Create An Approval Hook

import { createHook } from "@anvia/core";
import { ApprovalRuntimeService } from "../approvals/approval-runtime.service";

export function createApprovalHook(input: {
  userId: string;
  approvalRunId: string;
  approvalRuntime: ApprovalRuntimeService;
}) {
  return createHook({
    async onToolCall({ toolName, args, tool }) {
      if (toolName !== "refund_order") {
        return tool.run();
      }

      const approved = await input.approvalRuntime.waitForDecision({
        userId: input.userId,
        approvalRunId: input.approvalRunId,
        toolName,
        args,
      });

      return approved ? tool.run() : tool.skip("Refund was not approved.");
    },
  });
}

ApprovalRuntimeService is your own Nest provider. It is not exported by Anvia.

3. Create The Approval Runtime Service

import { Injectable } from "@nestjs/common";

type ApprovalRequest = {
  userId: string;
  approvalRunId: string;
  toolName: string;
  args: string;
};

type ApprovalDecision = {
  approved: boolean;
  reason?: string;
};

@Injectable()
export class ApprovalRuntimeService {
  private readonly waiters = new Map<string, (decision: ApprovalDecision) => void>();

  async waitForDecision(request: ApprovalRequest): Promise<boolean> {
    const approval = await this.approvals.create({
      ...request,
      status: "pending",
    });

    await this.notifications.notifyReviewers({ approvalId: approval.id });

    const decision = await new Promise<ApprovalDecision>((resolve) => {
      this.waiters.set(approval.id, resolve);
    });

    this.waiters.delete(approval.id);
    return decision.approved;
  }

  async decide(input: { approvalId: string; approved: boolean; reason?: string }) {
    await this.approvals.updateDecision(input);

    this.waiters.get(input.approvalId)?.({
      approved: input.approved,
      reason: input.reason,
    });
  }
}

The Map is only a single-process waiter. Use durable storage plus queue, pub/sub, websocket, or polling workers for production.

4. Add A Decision Controller

import { Body, Controller, Param, Post } from "@nestjs/common";
import { z } from "zod";
import { ApprovalRuntimeService } from "./approval-runtime.service";

const DecisionRequest = z.object({
  approved: z.boolean(),
  reason: z.string().optional(),
});

@Controller("api/approvals")
export class ApprovalsController {
  constructor(private readonly approvalRuntime: ApprovalRuntimeService) {}

  @Post(":id/decision")
  async decide(@Param("id") approvalId: string, @Body() body: unknown) {
    const decision = DecisionRequest.parse(body);
    await this.approvalRuntime.decide({ approvalId, ...decision });
    return { ok: true };
  }
}

Next

Add NestJS tests in Setup Tests. Core concepts: Human in the Loop, Approval by Hooks, and Approval Runtimes.