Express

09 Human in the Loop

Add approvals and reviewer decisions to Express Anvia routes.

Express can expose agent routes and reviewer routes from the same server. Anvia provides hooks; your app provides approval storage and reviewer workflows.

1. Use Studio During Development

import { Studio } from "@anvia/studio";
import { supportAgent } from "../ai/support-agent";

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

Studio helps inspect pending approvals locally. Production reviewer permissions and notifications belong to your Express app.

2. Create A Hook

import { createHook } from "@anvia/core";
import { approvalRuntime } from "../approvals/runtime";

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

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

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

approvalRuntime is not imported from Anvia. It is your module for database records, notifications, reviewer UI, and waiter resolution.

3. Create The Approval Runtime

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

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

export function createApprovalRuntime() {
  const waiters = new Map<string, (decision: ApprovalDecision) => void>();

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

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

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

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

    async listPendingForReviewer(reviewerId: string) {
      return db.approval.findMany({
        where: { reviewerId, status: "pending" },
        orderBy: { createdAt: "asc" },
      });
    },

    async decide(input: { approvalId: string; approved: boolean; reason?: string }) {
      await db.approval.update({
        where: { id: input.approvalId },
        data: {
          status: input.approved ? "approved" : "rejected",
          decisionReason: input.reason,
          resolvedAt: new Date(),
        },
      });

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

export const approvalRuntime = createApprovalRuntime();

Use durable storage plus queue, pub/sub, websocket, or polling workers for production. The Map only works inside one process.

4. Add Reviewer Routes

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

supportRouter.get("/approvals", requireUser, async (req, res, next) => {
  try {
    res.json(await approvalRuntime.listPendingForReviewer(req.user.id));
  } catch (error) {
    next(error);
  }
});

supportRouter.post("/approvals/:id/decision", requireUser, async (req, res, next) => {
  try {
    const decision = DecisionRequest.parse(req.body);
    await approvalRuntime.decide({ approvalId: req.params.id, ...decision });
    res.json({ ok: true });
  } catch (error) {
    next(error);
  }
});

Next

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