AI Agents

Human-in-the-Loop

A common pre-requisite for running AI agents in production is the ability to wait for human input or external events before proceeding.

Workflow DevKit's webhook and hook primitives enable "human-in-the-loop" patterns where workflows pause until a human takes action, allowing smooth resumption of workflows even after days of inactivity, and provides stability across code deployments.

If you need to react to external events programmatically, see the hooks documentation for more information. This part of the guide will focus on the human-in-the-loop pattern, which is a subset of the more general hook pattern.

How It Works

createWebhook() generates a unique URL that can be used to resume a workflow. This can be packaged as a tool for the Agent to call, or used directly in your backend code.

The workflow emits a data chunk containing the webhook URL, sending it to the consumer of the workflow, such as a chat UI.

The workflow pauses at await webhook - no compute resources are consumed while waiting for the human to take action.

The user of the workflow (e.g. a human in a chat UI) calls the webhook URL, optionally sending data back to the workflow.

The workflow receives any data sent with the webhook (e.g. approval status, comments) and resumes execution.

Creating an Approval Tool

Add a tool that allows the agent to deliberately pauses execution until a human approves or rejects:

Implement the tool

Create a tool with an execute function that creates a webhook and emits a data chunk containing the webhook URL.

ai/tools/human-approval.ts
import { tool, type UIMessageChunk } from 'ai';
import { createWebhook, getWritable } from 'workflow'; 
import { z } from 'zod';

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
  const request = await webhook; 
  const { approved, comment } = await request.json(); 

  if (!approved) {
    return `Action rejected: ${comment || 'No reason provided'}`;
  }

  return `Approved${comment ? ` with comment: ${comment}` : ''}`;
}

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,
});

// This is just a helper function to emit the approval request to the UI
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();
}

The createWebhook() function must be called from within a workflow context, not from a step. This is why executeHumanApproval does not have "use step", but the stream write operation requires a step context, which is why emitApprovalRequest is a separate function with "use step".

Create the Approval Component

The UI receives a data chunk with type data-approval-required. Build a component that displays the approval request and handles the user's decision. Instead of giving the human UI to resolve this hook, this could also be sent to an external service, e.g. as a payment provider callback.

components/approval-button.tsx
'use client';

import { useState } from 'react';

interface ApprovalData {
  url: string;
  message: string;
}

export function ApprovalButton({ data }: { data: ApprovalData }) {
  const [comment, setComment] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isComplete, setIsComplete] = useState(false);

  const handleSubmit = async (approved: boolean) => {
    setIsSubmitting(true);
    try {
      await fetch(data.url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ approved, comment }),
      });
      setIsComplete(true);
    } finally {
      setIsSubmitting(false);
    }
  };

  if (isComplete) {
    return <div className="text-muted-foreground">Response submitted</div>;
  }

  return (
    <div className="border rounded-lg p-4 space-y-4">
      <p className="font-medium">{data.message}</p>

      <textarea
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        placeholder="Add a comment (optional)..."
        className="w-full border rounded p-2 text-sm"
        rows={2}
      />

      <div className="flex gap-2">
        <button
          onClick={() => handleSubmit(true)}
          disabled={isSubmitting}
          className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
        >
          {isSubmitting ? 'Submitting...' : 'Approve'}
        </button>
        <button
          onClick={() => handleSubmit(false)}
          disabled={isSubmitting}
          className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
        >
          {isSubmitting ? 'Submitting...' : 'Reject'}
        </button>
      </div>
    </div>
  );
}

Render in Your Chat

Handle the custom data chunk type in your message renderer:

components/chat.tsx
import { ApprovalButton } from './approval-button';

// Inside your message rendering:
{m.parts.map((part, i) => {
  if (part.type === 'text') {
    return <span key={i}>{part.text}</span>;
  }
  if (part.type === 'data-approval-required') {
    return <ApprovalButton key={i} data={part.data} />;
  }
  return null;
})}

Programmatic Hook Resumption

For scenarios where you need your backend code to resume a workflow (not just UI interactions), or when you want type-safe validation of approval data, use defineHook(). This approach is useful for:

  • Backend services that need to approve/reject based on business logic
  • External systems that call your API to resume workflows
  • Type-safe validation of approval data with Zod schemas

Define a typed hook with a Zod schema for validation:

ai/hooks/deployment-approval.ts
import { defineHook } from 'workflow';
import { z } from 'zod';

// Export the hook so it can be used in both the tool and API routes
export const deploymentApprovalHook = defineHook({
  schema: z.object({
    approved: z.boolean(),
    approvedBy: z.string(),
    environment: z.enum(['staging', 'production']),
    notes: z.string().optional(),
  }),
});

Use the hook in your tool instead of createWebhook():

ai/tools/deployment-approval.ts
import { tool } from 'ai';
import { getWritable } from 'workflow';
import { z } from 'zod';
import type { UIMessageChunk } from 'ai';
import { deploymentApprovalHook } from '../hooks/deployment-approval';

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 backend API:

app/api/approve-deployment/route.ts
import { deploymentApprovalHook } from '@/ai/hooks/deployment-approval';

export async function POST(request: Request) {
  const { token, approved, approvedBy, environment, notes } = await request.json();

  try {
    // Schema validation happens automatically
    await deploymentApprovalHook.resume(token, { 
      approved,
      approvedBy,
      environment,
      notes,
    });

    return Response.json({ success: true });
  } catch (error) {
    return Response.json(
      { error: 'Invalid token or validation failed' },
      { status: 400 }
    );
  }
}

This API can be called by:

  • Your UI components (passing the token from the data chunk)
  • Backend services that need to programmatically approve/reject
  • External systems via API integration