Human-in-the-Loop
Some agent actions require human approval before proceeding - deploying to production, sending emails to customers, or making financial transactions. Workflow DevKit's webhook and hook primitives enable "human-in-the-loop" patterns where workflows pause until a human takes action.
Creating an Approval Tool
Add a tool that pauses the agent until a human approves or rejects:
import { tool } from 'ai';
import { createWebhook, getWritable } from 'workflow';
import { z } from 'zod';
import type { UIMessageChunk } from 'ai';
async function emitApprovalRequest(
{ url, message }: { url: string; message: string },
{ toolCallId }: { toolCallId: string }
) {
'use step';
const writable = getWritable<UIMessageChunk>();
const writer = writable.getWriter();
await writer.write({
id: toolCallId,
type: 'data-approval-required',
data: { url, message },
});
writer.releaseLock();
}
async function executeHumanApproval(
{ message }: { message: string },
{ toolCallId }: { toolCallId: string }
) {
// Note: No "use step" - webhooks are workflow-level primitives
const webhook = createWebhook();
// Emit the approval URL to the UI
await emitApprovalRequest(
{ url: webhook.url, message },
{ toolCallId }
);
// Workflow pauses here until the webhook is called
await webhook;
return 'Approval received. Proceeding with action.';
}
export const humanApproval = tool({
description: 'Request human approval before proceeding with an action',
inputSchema: z.object({
message: z.string().describe('Description of what needs approval'),
}),
execute: executeHumanApproval,
});The createWebhook() function must be called from within a workflow context, not from a step. This is why executeHumanApproval does not have "use step".
How It Works
- The agent calls the
humanApprovaltool with a message describing what needs approval createWebhook()generates a unique URL that can resume the workflow- The tool emits a data chunk containing the approval URL to the UI
- The workflow pauses at
await webhook- no compute resources are consumed - When a human visits the webhook URL, the workflow resumes
- The tool returns and the agent continues with the approved action
Handling the Approval UI
The UI receives a data chunk with type data-approval-required. Display an approval button that triggers the webhook:
'use client';
interface ApprovalData {
url: string;
message: string;
}
export function ApprovalButton({ data }: { data: ApprovalData }) {
const handleApprove = async () => {
await fetch(data.url, { method: 'POST' });
};
return (
<div>
<p>{data.message}</p>
<button onClick={handleApprove}>Approve</button>
</div>
);
}Receiving Approval Data
To receive data from the approval action (such as approve/reject status or comments), read the webhook request body:
async function executeHumanApproval(
{ message }: { message: string },
{ toolCallId }: { toolCallId: string }
) {
const webhook = createWebhook();
await emitApprovalRequest({ url: webhook.url, message }, { toolCallId });
const request = await webhook;
const { approved, comment } = await request.json();
if (!approved) {
return `Action rejected: ${comment}`;
}
return `Approved with comment: ${comment}`;
}The UI sends the approval data in the request body:
const handleApprove = async () => {
await fetch(data.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: true, comment: 'Looks good!' }),
});
};
const handleReject = async () => {
await fetch(data.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: false, comment: 'Not ready yet' }),
});
};Using Hooks for Type-Safe Approvals
For stronger type safety, use defineHook() with a schema:
import { tool } from 'ai';
import { defineHook, getWritable } from 'workflow';
import { z } from 'zod';
import type { UIMessageChunk } from 'ai';
// Define a typed hook for deployment approvals
const deploymentApprovalHook = defineHook({
schema: z.object({
approved: z.boolean(),
approvedBy: z.string(),
environment: z.enum(['staging', 'production']),
}),
});
async function emitDeploymentApproval(
token: string,
environment: string,
toolCallId: string
) {
'use step';
const writable = getWritable<UIMessageChunk>();
const writer = writable.getWriter();
await writer.write({
id: toolCallId,
type: 'data-deployment-approval',
data: { token, environment },
});
writer.releaseLock();
}
async function executeDeploymentApproval(
{ environment }: { environment: 'staging' | 'production' },
{ toolCallId }: { toolCallId: string }
) {
const hook = deploymentApprovalHook.create();
await emitDeploymentApproval(hook.token, environment, toolCallId);
const approval = await hook;
if (!approval.approved) {
return `Deployment to ${environment} rejected by ${approval.approvedBy}`;
}
return `Deployment to ${environment} approved by ${approval.approvedBy}`;
}
export const deploymentApproval = tool({
description: 'Request approval for a deployment',
inputSchema: z.object({
environment: z.enum(['staging', 'production']),
}),
execute: executeDeploymentApproval,
});Resume the hook from your approval API:
import { deploymentApprovalHook } from '@/ai/tools/deployment-approval';
export async function POST(request: Request) {
const { token, approved, approvedBy, environment } = await request.json();
try {
// Schema validation happens automatically
await deploymentApprovalHook.resume(token, {
approved,
approvedBy,
environment,
});
return Response.json({ success: true });
} catch (error) {
return Response.json(
{ error: 'Invalid token or validation failed' },
{ status: 400 }
);
}
}Use Cases
Email Approval
Wait for approval before sending an email to customers:
async function executeSendEmail(
{ recipients, subject, body }: EmailParams,
{ toolCallId }: { toolCallId: string }
) {
const webhook = createWebhook();
await emitEmailApproval({
url: webhook.url,
recipients,
subject,
preview: body.substring(0, 200),
}, toolCallId);
const request = await webhook;
const { approved } = await request.json();
if (!approved) {
return 'Email cancelled by user';
}
await sendEmail({ recipients, subject, body });
return `Email sent to ${recipients.length} recipients`;
}Multi-Step Approval
Chain multiple approvals for high-risk actions:
export async function criticalActionWorkflow(action: string) {
'use workflow';
// First approval: Team lead
const leadApproval = await requestApproval({
role: 'team-lead',
action,
});
if (!leadApproval.approved) {
return { status: 'rejected', stage: 'team-lead' };
}
// Second approval: Manager
const managerApproval = await requestApproval({
role: 'manager',
action,
});
if (!managerApproval.approved) {
return { status: 'rejected', stage: 'manager' };
}
await executeCriticalAction(action);
return { status: 'completed' };
}Related Documentation
- Hooks & Webhooks - Complete guide to hooks and webhooks
createWebhook()API Reference - Webhook configuration optionsdefineHook()API Reference - Type-safe hook definitions