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.
