TanStack Start

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:

RoutePurpose
POST /api/support/runsCreate a run and pending approval record
GET /api/approvalsList pending approvals for the reviewer
POST /api/approvals/:id/decisionResolve 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.