Hono

09 Human in the Loop

Add approvals and human feedback to Hono Anvia routes.

Hono is a good fit for human-in-the-loop routes because the same app can expose agent runs, approval lists, and decision endpoints.

1. Use Studio During Development

Add approval metadata to protected tools, then register the built agent in Studio:

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

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

Studio gives you a local approval UI. Your Hono app still owns production auth and reviewer permissions.

2. Use A Request Hook In Hono

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

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 your own module. It is not imported from @anvia/core or any Anvia package.

Attach the hook inside the route:

import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

const SupportRequest = z.object({
  message: z.string().trim().min(1, "message is required"),
});

app.post("/api/support", zValidator("json", SupportRequest), async (c) => {
  const userId = c.get("userId");
  const { message } = c.req.valid("json");
  const approvalRunId = crypto.randomUUID();

  const response = await supportAgent
    .prompt(message)
    .requestHook(createApprovalHook({ userId, approvalRunId }))
    .send();

  return c.json({ output: response.output });
});

3. Create The Approval Runtime

approvalRuntime is not provided by Anvia. It is your Hono application's approval service: create a pending record, notify reviewers, wait for a decision route to resolve the pending promise, then return the boolean to the hook.

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: {
          userId: request.userId,
          approvalRunId: request.approvalRunId,
          toolName: request.toolName,
          args: request.args,
          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;
      reviewerId: string;
      approved: boolean;
      reason?: string;
    }): Promise<void> {
      await db.approval.update({
        where: { id: input.approvalId },
        data: {
          status: input.approved ? "approved" : "rejected",
          reviewerId: input.reviewerId,
          decisionReason: input.reason,
          resolvedAt: new Date(),
        },
      });

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

export const approvalRuntime = createApprovalRuntime();

The Map is only a simple waiter for one Node process. In production, keep records in durable storage and resolve waiters through your queue, pub/sub, websocket, or polling worker.

4. Add Reviewer Routes

const ApprovalDecisionRequest = z.object({
  approved: z.boolean(),
});

app.get("/api/approvals", async (c) => {
  const userId = c.get("userId");
  return c.json(await approvalRuntime.listPendingForReviewer(userId));
});

app.post(
  "/api/approvals/:id/decision",
  zValidator("json", ApprovalDecisionRequest),
  async (c) => {
    const userId = c.get("userId");
    const { approved } = c.req.valid("json");

    await approvalRuntime.decide({
      approvalId: c.req.param("id"),
      reviewerId: userId,
      approved,
    });

    return c.json({ ok: true });
  },
);

Use zValidator("json", schema) on the decision route the same way as prompt routes.

Next

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