09 Human in the Loop
Add approvals and human feedback to TanStack Start Anvia routes.
Human-in-the-loop work belongs in server routes or server functions. The agent can wait on your approval service before a protected tool runs.
1. Use Studio During Development
Add approval metadata to a tool and register the same built agent in Studio:
import { Studio } from "@anvia/studio";
import { supportAgent } from "~/ai/support-agent";
new Studio([supportAgent]).start({ port: 4021 });Studio handles the approval UI for tools with approval metadata. Your TanStack Start app can still expose its own routes for production users.
2. Use A Request Hook In Server Code
import { createHook } from "@anvia/core";
import { approvalRuntime } from "~/approvals/runtime";
function createApprovalHook(input: { userId: string; runId: string }) {
return createHook({
async onToolCall({ toolName, args, tool }) {
if (toolName !== "refund_order") {
return tool.run();
}
const approved = await approvalRuntime.waitForDecision({
userId: input.userId,
runId: input.runId,
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 from a server route:
const response = await supportAgent
.prompt(message)
.requestHook(createApprovalHook({ userId, runId }))
.send();3. Create The Approval Runtime
approvalRuntime is your application runtime for reviewer state. Anvia only waits for the promise returned by waitForDecision(...); your app creates the pending record, shows it to reviewers, accepts the decision, and resolves the waiting promise.
type ApprovalRequest = {
userId: string;
runId: 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,
runId: request.runId,
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();This in-memory waiter works for a single running process. For production, store approvals durably and resolve waiters through your queue, pub/sub, websocket, or polling worker.
4. Return Pending State From App Routes
If a run can wait for approval longer than your HTTP timeout, split the workflow:
| Route | Purpose |
|---|---|
POST /api/support/runs | Create a run and pending approval record |
GET /api/approvals | List pending approvals for the reviewer |
POST /api/approvals/:id/decision | Resolve the waiting approval promise |
For short internal workflows, the route can wait directly. For user-facing flows, store state and notify the client.
Next
Add route tests in Setup Tests. Core concepts: Human in the Loop, Approval by Hooks, Approval Runtimes, and Studio Tool Approvals.
