09 Human in the Loop
Add approvals and human feedback to Next.js Anvia routes.
Human-in-the-loop work can live in Studio during development or in your own Next.js routes when production users need to approve actions.
1. Use Studio For Local Approval UI
Add approval metadata to side-effect tools:
const refundOrder = createTool({
name: "refund_order",
description: "Issue a refund.",
input: z.object({
orderId: z.string(),
amount: z.number().positive(),
}),
approval: {
when: ({ args }) => args.amount > 100,
reason: ({ args }) => `Review refund of $${args.amount} for ${args.orderId}.`,
rejectMessage: "Refund was not approved.",
},
async execute({ orderId, amount }) {
return refunds.create({ orderId, amount });
},
});Run the same built agent in Studio from a server-only script:
import { Studio } from "@anvia/studio";
import { supportAgent } from "@/app/ai/support-agent";
new Studio([supportAgent]).start({ port: 4021 });Studio reads the approval metadata and waits for a reviewer before running protected tools.
2. Use A Request Hook For Product Approval
Use a hook when your app owns the approval table, reviewer UI, notification, or timeout.
import { createHook } from "@anvia/core";
import { approvalRuntime } from "@/app/approvals/runtime";
function createApprovalHook(input: { userId: string; conversationId: string }) {
return createHook({
async onToolCall({ toolName, args, tool }) {
if (toolName !== "refund_order") {
return tool.run();
}
const approved = await approvalRuntime.waitForDecision({
userId: input.userId,
conversationId: input.conversationId,
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 in the route:
const response = await supportAgent
.prompt(message)
.requestHook(createApprovalHook({ userId, conversationId }))
.send();3. Create The Approval Runtime
approvalRuntime is application code, not an Anvia export. It is the small runtime that creates a pending approval record, notifies reviewers, waits until one of your routes or jobs resolves it, then returns the decision to the hook.
type ApprovalRequest = {
userId: string;
conversationId: 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,
conversationId: request.conversationId,
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 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 the waiting mechanism for a single Node process. In production, keep approval records in durable storage and use your app's realtime channel, queue, pub/sub system, or polling worker to resolve waiters.
4. Keep Approval State In Your App
| State | Owner |
|---|---|
| Pending approval record | Your database |
| Reviewer authorization | Your app |
| Notification or websocket | Your app |
| Tool execution after approval | Anvia via tool.run() |
| Rejection message to model | Anvia via tool.skip(...) |
Next
Add tests in Setup Tests. Core concepts: Human in the Loop, Approval by Hooks, Approval Runtimes, and Studio Tool Approvals.
