> ## Documentation Index
> Fetch the complete documentation index at: https://tfh-takis-world-id-marketing-names.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrate

> Add human approval workflows to AI agents using World ID.

<video className="m-auto" width="700" autoPlay muted loop playsInline controls>
  <source src="https://mintcdn.com/tfh-takis-world-id-marketing-names/01vxDt2_IN-GZVWE/images/docs/human-in-the-loop/hitl.mp4?fit=max&auto=format&n=01vxDt2_IN-GZVWE&q=85&s=52a7863ae64e1fddcb1c76cf9b31049c" type="video/mp4" data-path="images/docs/human-in-the-loop/hitl.mp4" />
</video>

<p className="text-center text-sm">*AI agent pauses for World ID approval before booking a flight*</p>

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](/world-id/overview) — no bots, no spoofing, no replay.

Built on the [Workflow SDK](https://useworkflow.dev) and the [Vercel AI SDK](https://ai-sdk.dev).

## Install

```bash theme={"system"}
# 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

```bash theme={"system"}
# 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_...
```

Get these from the [World developer portal](https://developer.world.org) by creating an app.

## Step 1: Define the workflow

```ts theme={"system"}
// 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

<Warning>
  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](https://github.com/worldcoin/human-in-the-loop/tree/main/examples/flight-booking) for proof verification and parameter binding.
</Warning>

```ts theme={"system"}
// 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.

<CodeGroup>
  ```tsx HumanApproval component theme={"system"}
  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
  })}
  ```

  ```tsx useHumanApproval hook theme={"system"}
  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}
          />
        )}
      </>
    )
  }
  ```
</CodeGroup>

### Flight booking example

Check out [Flight booking example](https://github.com/worldcoin/human-in-the-loop/tree/main/examples/flight-booking) for a complete implementation of a human-in-the-loop workflow with World ID approval.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.