惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
美团技术团队
Microsoft Azure Blog
Microsoft Azure Blog
V
Visual Studio Blog
Jina AI
Jina AI
博客园_首页
M
MIT News - Artificial intelligence
D
DataBreaches.Net
L
LangChain Blog
宝玉的分享
宝玉的分享
F
Fortinet All Blogs
A
About on SuperTechFans
月光博客
月光博客
Stack Overflow Blog
Stack Overflow Blog
Google DeepMind News
Google DeepMind News
N
Netflix TechBlog - Medium
Y
Y Combinator Blog
腾讯CDC
Vercel News
Vercel News
雷峰网
雷峰网
GbyAI
GbyAI
aimingoo的专栏
aimingoo的专栏
阮一峰的网络日志
阮一峰的网络日志
博客园 - 【当耐特】

Workflow SDK Documentation

Patterns for Defining Tools Human-in-the-Loop Building Durable AI Agents Queueing User Messages Resumable Streams Sleep, Suspense, and Scheduling Streaming Updates from Tools API Reference Workflow Globals Changelog Resilient run start Cookbook Building a World Deploying Astro Express Fastify Hono Getting Started NestJS Next.js Nitro Nuxt Python SvelteKit Vite corrupted-event-log fetch-in-workflow hook-conflict Errors
Human-in-the-Loop
2026-05-31 · via Workflow SDK Documentation

Pause an AI agent to wait for human approval, then resume based on the decision.

Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds.

  • Booking confirmations where users must approve before charges are made
  • Content publishing gates where an editor must sign off
  • Any agent action where the cost of getting it wrong justifies a human check
  • Actions with side effects that can't be easily undone

Create a typed hook using defineHook(). When the agent calls the approval tool, the tool emits a custom data part to the stream so the client can render approval controls, then creates a hook and suspends. An API route resumes the hook with the decision.

Workflow

import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, sleep, getWritable } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";

// Exported so the approval API route can call .resume()
export const bookingApprovalHook = defineHook({ 
  schema: z.object({
    approved: z.boolean(),
    comment: z.string().optional(),
  }),
});

async function searchFlights({ from, to, date }: {
  from: string;
  to: string;
  date: string;
}) {
  "use step";
  const res = await fetch(
    `https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
  );
  return res.json();
}

async function confirmBooking({ flightId, passenger }: {
  flightId: string;
  passenger: string;
}) {
  "use step";
  const res = await fetch("https://api.example.com/bookings", {
    method: "POST",
    body: JSON.stringify({ flightId, passenger }),
  });
  return res.json();
}

// Stream a custom data part so the client can render the approval UI.
// This MUST run before the hook suspends the workflow — otherwise
// the tool-invocation won't appear in the stream until the tool returns,
// and the client would have no way to show approval buttons.
async function emitApprovalRequest(details: {
  flightId: string;
  passenger: string;
  price: number;
  toolCallId: string;
}) {
  "use step";
  const writer = getWritable<UIMessageChunk>().getWriter();
  try {
    await writer.write({
      type: "data-approval-needed", 
      id: details.toolCallId,
      data: details,
    } as UIMessageChunk);
  } finally {
    writer.releaseLock();
  }
}

// Stream the resolution so the client can update the approval card.
async function emitApprovalResolved(details: {
  toolCallId: string;
  result: string;
}) {
  "use step";
  const writer = getWritable<UIMessageChunk>().getWriter();
  try {
    await writer.write({
      type: "data-approval-resolved", 
      id: details.toolCallId,
      data: details,
    } as UIMessageChunk);
  } finally {
    writer.releaseLock();
  }
}

// No "use step" — hooks are workflow-level primitives
async function requestBookingApproval(
  { flightId, passenger, price }: {
    flightId: string;
    passenger: string;
    price: number;
  },
  { toolCallId }: { toolCallId: string }
) {
  // Emit to the stream before suspending so the UI can show buttons
  await emitApprovalRequest({ flightId, passenger, price, toolCallId }); 

  const hook = bookingApprovalHook.create({ token: toolCallId });

  // Race: human decision vs. timeout
  const result = await Promise.race([
    hook.then((payload) => ({ type: "decision" as const, ...payload })),
    sleep("24h").then(() => ({ type: "timeout" as const, approved: false as const })),
  ]);

  if (result.type === "timeout") {
    const msg = "Booking request expired.";
    await emitApprovalResolved({ toolCallId, result: msg }); 
    return msg;
  }
  if (!result.approved) {
    const msg = `Rejected: ${result.comment || "No reason given"}`;
    await emitApprovalResolved({ toolCallId, result: msg }); 
    return msg;
  }

  const booking = await confirmBooking({ flightId, passenger });
  const msg = `Booked! Confirmation: ${booking.confirmationId}`;
  await emitApprovalResolved({ toolCallId, result: msg }); 
  return msg;
}

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

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions: "You help book flights. Always request approval before booking.",
    tools: {
      searchFlights: {
        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,
      },
      requestBookingApproval: {
        description: "Request human approval before booking a flight",
        inputSchema: z.object({
          flightId: z.string().describe("Flight ID to book"),
          passenger: z.string().describe("Passenger name"),
          price: z.number().describe("Total price"),
        }),
        execute: requestBookingApproval,
      },
    },
  });

  await agent.stream({
    messages,
    writable: getWritable<UIMessageChunk>(),
  });
}

Approval API route

The approval route imports the hook definition and calls .resume() with the tool call ID as the token:

import { bookingApprovalHook } from "@/app/workflows/booking-agent";

export async function POST(req: Request) {
  const { toolCallId, approved, comment } = await req.json();

  await bookingApprovalHook.resume(toolCallId, { approved, comment }); 

  return Response.json({ success: true });
}

Client rendering

Listen for data-approval-needed and data-approval-resolved custom data parts in the message stream. The approval tool invocation itself won't appear until the tool returns, so the custom data parts are the mechanism for showing and updating the approval UI.

// Scan all messages for the resolution
const approvalResult = messages
  .flatMap((m) => m.parts)
  .find((p) => p.type === "data-approval-resolved")
  ?.data?.result;

// In your message parts loop:
{message.parts.map((part, i) => {
  if (part.type === "data-approval-needed") { 
    const { flightId, passenger, price, toolCallId } = part.data;
    if (approvalResult) {
      return <div key={i}>Result: {approvalResult}</div>;
    }
    return (
      <div key={i} className="rounded-lg border p-4 space-y-3">
        <div className="text-sm">
          <div>Flight: {flightId}</div>
          <div>Passenger: {passenger}</div>
          <div>Price: ${price}</div>
        </div>
        <div className="flex gap-2">
          <button onClick={() => approve(toolCallId)}>Approve</button> {}
          <button onClick={() => reject(toolCallId)}>Reject</button> {}
        </div>
      </div>
    );
  }
  // Hide the requestBookingApproval tool-invocation part
  if (part.type === "tool-invocation" &&
      part.toolInvocation.toolName === "requestBookingApproval") {
    return null;
  }
  // ... other part types
})}
  1. defineHook() with schema — creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it.
  2. toolCallId as token — the approval tool uses the tool call ID as the hook token, naturally linking the hook to the specific tool invocation.
  3. emitApprovalRequest step — writes a data-approval-needed custom data part to the stream before the hook suspends. Without this, the client would never see the approval controls because tool invocations don't stream until the tool returns.
  4. No "use step" on the approval tool — the tool runs at the workflow level because defineHook().create() is a workflow primitive. It calls step functions (emitApprovalRequest, emitApprovalResolved, confirmBooking) for I/O.
  5. Promise.race with sleep — the approval races against a durable timeout. If nobody responds, the workflow continues with an expiration message.
  6. emitApprovalResolved step — writes the outcome to the stream so the client can update the card immediately, without waiting for the tool-invocation result.
  • Change the approval schema — add fields like reason, amount, reviewerEmail to match your domain.
  • Multiple approval gates — the pattern works for any number of tools. Each tool creates its own hook with its own toolCallId.
  • Escalation — if the first approver doesn't respond, use sleep() + another hook to escalate to a backup reviewer.
  • Adjust timeout — use "24h" for production, shorter durations for demos.
  • Workflow-level vs step tools — tools that use sleep(), defineHook(), or other workflow primitives must NOT use "use step". Tools with only I/O (API calls, DB queries) should use "use step" for retries.