---
title: Human-in-the-Loop
description: Pause a WorkflowAgent for human approval before a consequential tool executes.
type: guide
summary: Use WorkflowAgent's needsApproval option and AI SDK approval responses to build durable human approval flows.
---

<CopyPrompt
  text="Add a human approval gate to this AI SDK WorkflowAgent. Define the consequential action with AI SDK's `tool()` helper, set `needsApproval: true` (or an input-dependent function), and keep the tool's `execute` function as a durable `&quot;use step&quot;` function. Configure `experimental_toolApprovalSecret` with the name of a high-entropy secret environment variable so client-supplied approvals are signed and verified. Stream `ModelCallStreamPart` values through `getWritable()` and convert them with `createModelCallToUIChunkTransform()` in the API route. In the client, render tool parts whose state is `approval-requested`, call `addToolApprovalResponse()` with the approval ID and decision, and use `lastAssistantMessageIsCompleteWithApprovalResponses` to continue automatically. Verify approve and reject paths, invalid or missing signatures, duplicate responses, and that the side effect never runs before approval."
/>

Use this pattern when an AI agent needs confirmation before performing an action such as booking, purchasing, publishing, or deleting data. `WorkflowAgent` makes approval a first-class part of the durable agent loop: it emits an approval request, pauses before the tool executes, and resumes after the user responds.

## When to use this

- Booking confirmations where users must approve before charges are made
- Content publishing gates where an editor must sign off
- Agent actions where the cost of an error justifies human review
- Side effects that are difficult to reverse

## Define an approval-gated tool

Set `needsApproval` on the tool. Keep the action itself in a step so it receives Workflow retries and observability only after approval succeeds.

```typescript title="workflows/booking-agent.ts" lineNumbers
import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
import {
  tool,
  type InferUITools,
  type ModelMessage,
  type UIMessage,
} from "ai";
import { getWritable } from "workflow";
import { z } from "zod";

async function searchFlights({ from, to, date }: {
  from: string;
  to: string;
  date: string;
}) {
  "use step";

  const response = await fetch(
    `https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
  );
  return response.json();
}

async function confirmBooking({ flightId, passenger, price }: {
  flightId: string;
  passenger: string;
  price: number;
}) {
  "use step";

  const response = await fetch("https://api.example.com/bookings", {
    method: "POST",
    body: JSON.stringify({ flightId, passenger, price }),
  });
  return response.json();
}

export const bookingTools = {
  searchFlights: tool({
    description: "Search for available flights",
    inputSchema: z.object({
      from: z.string().describe("Departure airport code"),
      to: z.string().describe("Arrival airport code"),
      date: z.string().describe("Travel date (YYYY-MM-DD)"),
    }),
    execute: searchFlights,
  }),
  confirmBooking: tool({
    description: "Book a selected flight for a passenger",
    inputSchema: z.object({
      flightId: z.string(),
      passenger: z.string(),
      price: z.number(),
    }),
    needsApproval: true, // [!code highlight]
    execute: confirmBooking,
  }),
};

export type BookingAgentUIMessage = UIMessage<
  unknown,
  never,
  InferUITools<typeof bookingTools>
>;

export async function bookingAgent(messages: ModelMessage[]) {
  "use workflow";

  const agent = new WorkflowAgent({
    model: "spacexai/grok-4.6",
    instructions: "Help the user find and book flights.",
    tools: bookingTools,
    experimental_toolApprovalSecret: { // [!code highlight]
      environmentVariable: "WORKFLOW_TOOL_APPROVAL_SECRET", // [!code highlight]
    }, // [!code highlight]
  });

  return agent.stream({
    messages,
    writable: getWritable<ModelCallStreamPart>(),
  });
}
```

## Sign approval requests

When approval responses come from client-supplied message history, configure `experimental_toolApprovalSecret` as shown above. `WorkflowAgent` signs the approval ID, tool-call ID, tool name, and validated input when it emits the approval request, then verifies that signature before an approved tool can execute. Missing or invalid signatures prevent the action from running.

Set `WORKFLOW_TOOL_APPROVAL_SECRET` to a high-entropy secret in every environment that can execute the workflow. For example, generate one with `openssl rand -base64 32`, then store it in your deployment's secret environment variables. Only the environment variable name crosses the workflow boundary; the secret is read inside signing and verification steps and is never serialized into workflow history.

Signed approvals require `@ai-sdk/workflow` 2.0.16 or later.

`needsApproval` can also decide from the parsed tool input. For example, require approval only when a booking costs more than a threshold:

{/* @skip-typecheck: property excerpt */}
```typescript
needsApproval: async ({ price }) => price > 500,
```

## Start the workflow and transform its stream

`WorkflowAgent` stores `ModelCallStreamPart` values. Convert those durable parts into AI SDK UI chunks at the HTTP boundary:

```typescript title="app/api/chat/route.ts" lineNumbers
import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
} from "ai";
import { start } from "workflow/api";
import {
  bookingAgent,
  type BookingAgentUIMessage,
} from "@/workflows/booking-agent";

export async function POST(request: Request) {
  const { messages }: { messages: BookingAgentUIMessage[] } =
    await request.json();
  const modelMessages = await convertToModelMessages(messages);
  const run = await start(bookingAgent, [modelMessages]);

  return createUIMessageStreamResponse({
    stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()),
    headers: { "x-workflow-run-id": run.runId },
  });
}
```

## Render and answer approval requests

Approval requests arrive as typed tool parts with `state: "approval-requested"`. Call `addToolApprovalResponse()` with the approval ID. The AI SDK then sends the updated message history back to the route and `WorkflowAgent` continues the durable tool flow.

```tsx title="app/chat.tsx" lineNumbers
"use client";

import { useChat } from "@ai-sdk/react";
import { WorkflowChatTransport } from "@ai-sdk/workflow";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { useMemo } from "react";
import type { BookingAgentUIMessage } from "@/workflows/booking-agent";

export function Chat() {
  const transport = useMemo(
    () => new WorkflowChatTransport({ api: "/api/chat" }),
    []
  );
  const { messages, addToolApprovalResponse } = useChat<BookingAgentUIMessage>({
    transport,
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  return messages.map((message) =>
    message.parts.map((part) => {
      if (
        part.type !== "tool-confirmBooking" ||
        part.state !== "approval-requested" ||
        part.approval.isAutomatic
      ) {
        return null;
      }

      return (
        <div key={part.toolCallId}>
          <p>
            Book flight {part.input.flightId} for {part.input.passenger} at
            ${part.input.price}?
          </p>
          <button
            onClick={() =>
              addToolApprovalResponse({
                id: part.approval.id,
                approved: true,
              })
            }
          >
            Approve
          </button>
          <button
            onClick={() =>
              addToolApprovalResponse({
                id: part.approval.id,
                approved: false,
              })
            }
          >
            Reject
          </button>
        </div>
      );
    })
  );
}
```

## How it works

1. The model calls `confirmBooking` with validated input.
2. `needsApproval` prevents the tool's `execute` function from running and emits an approval request.
3. The durable stream preserves the request across disconnects and process restarts.
4. The client adds an approval response to the conversation.
5. If approved, `confirmBooking` runs as a durable step. If rejected, the model receives the denial and can respond without performing the side effect.

## Adapting the pattern

- **Conditional approval**: Return a boolean from `needsApproval` based on amount, tenant policy, or risk.
- **Timeouts and escalation**: Combine the surrounding workflow with `sleep()` and hooks when an approval must expire or escalate.
- **Audit context**: Include durable user and tenant identifiers in the workflow input, then record the approver in your application database.
- **Multiple gates**: Set `needsApproval` on every consequential tool independently.

## Key APIs

- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Durable AI SDK agent with first-class tool approvals
- [`tool()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool): Defines a typed tool and its approval policy
- [`getWritable()`](/docs/api-reference/workflow/get-writable): Stores durable model-call stream parts
- [`WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport): Reconnects interrupted chat streams
