AI agent pauses for World ID approval before booking a flight
Human-in-the-loop lets an AI agent pause mid-execution and wait for a real, verified human to approve an action before continuing. Every approval is cryptographically bound to the action via World ID — no bots, no spoofing, no replay. Built on the Workflow SDK and the Vercel AI SDK.Install
# Server — human-in-the-loop + peer dependencies
npm install @worldcoin/human-in-the-loop ai@^6 workflow @workflow/ai zod
# Client — React bindings + peer dependencies
npm install @worldcoin/human-in-the-loop-react @worldcoin/idkit ai@^6 react
Environment variables
# Server — used by @worldcoin/human-in-the-loop
WORLD_RP_ID=your_rp_id
WORLD_SIGNING_KEY=your_signing_key
# Client — used by the <HumanApproval> component (optional if passing appId prop)
NEXT_PUBLIC_WORLD_APP_ID=app_...
Step 1: Define the workflow
// src/workflows/chat/index.ts
import { DurableAgent } from '@workflow/ai/agent'
import { getWritable } from 'workflow'
import { openai } from '@workflow/ai/openai'
import type { ModelMessage, UIMessageChunk } from 'ai'
import { tools } from './steps/tools'
export async function chatWorkflow(messages: ModelMessage[]) {
// Durable workflow — can pause for hours/days and resume where it left off
'use workflow'
const writable = getWritable<UIMessageChunk>()
const agent = new DurableAgent({
model: openai('gpt-5.4'),
tools,
instructions:
'You are a helpful assistant. Before performing any sensitive action, call approveAction first, then pass its returned result as the `approval` argument to the action tool — never call the action tool without it.',
})
await agent.stream({ messages, writable })
}
Step 2: Register the approval tool
The default
action is the unique toolCallId. For a sensitive operation, your backend must bind the approval to the intended operation and its parameters, independently verify the proof, and consume the approval once before performing the side effect. A required approval input is not proof of authorization — tool inputs are LLM-generated. See the flight booking example for proof verification and parameter binding.// src/workflows/chat/steps/tools.ts
import { requestHumanAuthorization } from '@worldcoin/human-in-the-loop/workflows'
import { z } from 'zod'
const approvalInputSchema = z.object({
summary: z.string(),
flightNumber: z.string(),
})
const bookingInputSchema = z.object({
flightNumber: z.string(),
// One credential, so its nullifier identifies the approval below. Cap it at
// 32 bytes: the 3.0 verifier reads only the first 32, so a longer encoding
// would still verify but produce a new key.
approval: z
.object({
action: z.string(),
responses: z
.array(z.object({ nullifier: z.string().regex(/^0x[0-9a-fA-F]{1,64}$/) }).passthrough())
.length(1),
})
.passthrough(),
})
// Atomically records an approval key; returns false if it was already used.
// Back it with durable storage (e.g. a unique database key), not memory.
declare function consumeApproval(key: string): Promise<boolean>
export const tools = {
approveAction: {
description: 'Request human approval via World ID before a sensitive action.',
inputSchema: approvalInputSchema,
// Pauses the workflow, streams approval context to the client,
// waits for World ID proof, verifies it, then resumes.
// Bind this example's approval to the flight number.
execute: requestHumanAuthorization<z.infer<typeof approvalInputSchema>>({
action: ({ input }) => `booking:${input.flightNumber}`,
}),
},
bookFlight: {
description: 'Book the flight using the IDKitResult from approveAction as `approval`.',
// Requiring an object does not establish that its proof is authentic.
inputSchema: bookingInputSchema,
// Never trust that approveAction ran just because this tool was called:
// check the binding, re-verify the proof, and consume it once.
execute: async ({ flightNumber, approval }: z.infer<typeof bookingInputSchema>) => {
'use step'
const expectedAction = `booking:${flightNumber}`
if (approval.action !== expectedAction) {
throw new Error(`approval does not match this booking (expected action: ${expectedAction})`)
}
const rpId = process.env.WORLD_RP_ID
if (!rpId) throw new Error('WORLD_RP_ID is required to verify approvals')
const res = await fetch(`https://developer.world.org/api/v4/verify/${rpId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The approval is untrusted input: pin the environment so it can't
// select "staging" or "sandbox", which accept test proofs.
body: JSON.stringify({ ...approval, environment: 'production' }),
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
throw new Error(`approval failed World ID verification (${res.status}): ${await res.text()}`)
}
// One-time use, keyed on the proof's nullifier (bound by 3.0 and 4.0
// proofs). Parse it as the verifier does, so "0x01" and "0x1" share a
// key. Nullifiers repeat per person and action, so each person can book
// a flight number once; add a unique booking ID to the action to allow more.
const nullifier = BigInt(approval.responses[0].nullifier).toString(16)
if (!(await consumeApproval(`${expectedAction}:${nullifier}`))) {
throw new Error('approval already used')
}
// ...book the flight
},
},
// ...your other tools
}
Step 3: Render the approval on the client
This example uses the<HumanApproval> component, if you want to customize the UI you can use the useHumanApproval hook instead.
import { HumanApproval } from '@worldcoin/human-in-the-loop-react'
// Match on the tool name from Step 2. <HumanApproval> renders the World ID
// widget and POSTs the proof back to the server automatically.
{message.parts.map(part => {
if (part.type === 'tool-approveAction' && 'toolCallId' in part) {
return (
<HumanApproval
key={part.toolCallId}
message={message}
part={part}
/>
)
}
// ...your other part renderers
})}
import { useHumanApproval } from '@worldcoin/human-in-the-loop-react'
import { IDKitRequestWidget, orbLegacy } from '@worldcoin/idkit'
import { useState } from 'react'
function MyApproval({ message, part }) {
const [open, setOpen] = useState(false)
// ready: true once the server streams the approval context
// verify: POSTs the World ID proof to the server webhook
const { ready, action, rpContext, state, verify } = useHumanApproval(message, part)
if (state.status === 'verified') return <p>Approved.</p>
return (
<>
<button disabled={!ready} onClick={() => setOpen(true)}>
{state.status === 'verifying' ? 'Verifying...' : 'Approve'}
</button>
{state.status === 'error' && <p>{state.error.message}</p>}
{ready && (
<IDKitRequestWidget
open={open}
onOpenChange={setOpen}
onSuccess={() => {}}
handleVerify={verify}
app_id={process.env.NEXT_PUBLIC_WORLD_APP_ID as `app_${string}`}
action={action!}
rp_context={rpContext!}
preset={orbLegacy()}
allow_legacy_proofs={false}
/>
)}
</>
)
}