> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Add human handoff

> Let an end user reach a person, pause the model while a teammate owns the conversation, and hand it back

After this guide, an end user who asks for a person gets one: the agent flags the end user, stops answering, forwards each message to your team by email, and resumes when a teammate clears the flag. For a decision rather than a conversation, use a [workflow](/concepts/workflows) approval.

*Verified against lua-cli 3.33.0.*

**Before you begin**

* A project created with `lua init` and signed in with `lua auth configure` ([Install and sign in](/get-started/install)).
* An email channel on the agent ([Email](/channels/email)).
* Your team's inbox stored with `lua env production -k HANDOFF_EMAIL -v support@example.com`, and with `lua env sandbox`, which writes `.env` for `lua test`.

<Steps>
  <Step title="Add a tool that asks for a person">
    The model calls this tool when the end user asks for a person. It flags the [`User`](/reference/sdk/user) record, shared across channels and environments, and emails your team the user ID and webhook URL that hand the conversation back.

    ```ts src/skills/tools/RequestHumanTool.ts theme={null}
    import { LuaTool, User, Channels, env } from 'lua-cli';
    import { z } from 'zod';

    // No runtime API exposes the agent ID; copy agent.agentId from lua.skill.yaml.
    const RESUME_URL = 'https://webhook.heylua.ai/baseAgent_agent_1770000000000_k3xq9mz2p/resume-handoff';

    export default class RequestHumanTool implements LuaTool {
      name = 'request_human';
      description = 'Hand the conversation to a person when the user asks for one or the problem needs a human decision';
      inputSchema = z.object({
        reason: z.string().describe('One sentence on why a person is needed'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const user = await User.get();
        if (!user) throw new Error('No user in this conversation');
        const userId = user._luaProfile.userId;

        await user.update({ handoff: true, handoffReason: input.reason, handoffAt: new Date().toISOString() });

        const inbox = env('HANDOFF_EMAIL');
        if (inbox) {
          await Channels.email.send({
            to: { email: inbox },
            subject: `Handoff requested by ${user._luaProfile.fullName || userId}`,
            text: `${input.reason}\n\nUser ID: ${userId}\nResume: POST ${RESUME_URL} with {"userId":"${userId}"}`,
          });
        }
        return { handedOff: true, message: 'A teammate will take over this conversation shortly.' };
      }
    }
    ```
  </Step>

  <Step title="Block the model while the flag is set">
    A [preprocessor](/concepts/processors) runs before the model on every message and receives the end user first. `priority: 1` puts it before any other preprocessor; `block` ends the turn with `response` as the reply.

    ```ts src/preprocessors/HandoffGate.ts theme={null}
    import { PreProcessor, Channels, env } from 'lua-cli';

    export default new PreProcessor({
      name: 'handoff-gate',
      description: 'While a person owns the conversation, keep the model out and forward each message to the team',
      priority: 1,
      async execute(user, messages) {
        if (!user.data.handoff) return { action: 'proceed' };

        const text = messages.map((m) => (m.type === 'text' ? m.text : `[${m.type}]`)).join('\n');
        const inbox = env('HANDOFF_EMAIL');
        if (inbox) {
          await Channels.email.send({
            to: { email: inbox },
            subject: `New message from ${user._luaProfile.fullName || user._luaProfile.userId}`,
            text,
          });
        }
        return { action: 'block', response: 'A teammate has your conversation and will reply here.' };
      },
    });
    ```

    `Channels.send` has no Slack target and `user.send()` never picks Slack. To notify a Slack workspace, connect Slack as an [integration](/integrations/connect) and post through it; otherwise notify by email, WhatsApp, or SMS ([Notify a teammate another way](#notify-a-teammate-another-way)).
  </Step>

  <Step title="Add a way back">
    A [webhook](/concepts/webhooks) clears the flag when your team is done; it runs outside any conversation, so it looks the end user up by ID. `user.send()` delivers the closing message on the channel the end user last wrote from and mirrors it to the web widget; deployed, it resolves `true` whatever happened, so confirm delivery in `lua logs`. `secret` makes the platform reject calls without a valid `x-lua-signature` header; the compiler reads it, so use a literal or a `const`.

    ```ts src/webhooks/ResumeHandoffWebhook.ts theme={null}
    import { LuaWebhook, User } from 'lua-cli';
    import { z } from 'zod';

    // Read by the compiler, so a literal or const rather than env().
    const SIGNING_SECRET = 'replace-with-a-long-random-string';

    export default new LuaWebhook({
      name: 'resume-handoff',
      description: 'Give the conversation back to the agent, optionally with a closing message',
      secret: SIGNING_SECRET,
      bodySchema: z.object({ userId: z.string(), message: z.string().optional() }),
      async execute(event) {
        const { userId, message } = event.body as { userId: string; message?: string };
        const user = await User.get(userId);
        if (!user) return { resumed: false, reason: 'unknown user' };

        await user.unset('handoff', 'handoffReason', 'handoffAt');
        if (message) await user.send([{ type: 'text', text: message }]);
        return { resumed: true };
      },
    });
    ```
  </Step>

  <Step title="Register everything on the agent">
    The skill's `context` tells the model when to call the tool; the compiler bundles only what `LuaAgent` references.

    ```ts src/index.ts highlight={16-18} theme={null}
    import { LuaAgent, LuaSkill } from 'lua-cli';
    import RequestHumanTool from './skills/tools/RequestHumanTool';
    import handoffGate from './preprocessors/HandoffGate';
    import resumeHandoff from './webhooks/ResumeHandoffWebhook';

    const supportSkill = new LuaSkill({
      name: 'support',
      description: 'Answer support questions and hand over to a person when needed',
      context: 'Call request_human when the user asks for a person or when you cannot resolve the issue.',
      tools: [new RequestHumanTool()],
    });

    export default new LuaAgent({
      name: 'support-assistant',
      persona: 'You are the Acme support assistant.',
      skills: [supportSkill],
      preProcessors: [handoffGate],
      webhooks: [resumeHandoff],
    });
    ```
  </Step>

  <Step title="Run the handoff loop locally">
    `lua test` runs each primitive on your machine as you, the signed-in developer. Set your own flag with the tool; it emails the `HANDOFF_EMAIL` in `.env`.

    ```bash theme={null}
    lua test skill --name request_human --input '{"reason":"test"}' --ci
    ```

    ```text Output theme={null}
    ✅ Selected tool: request_human
    …
    ✅ Tool execution successful!

    Tool returned: Object — fields: handedOff, message
    Output:
    { handedOff: true, message: 'A teammate will take over this conversation shortly.' }
    ```

    The gate now blocks you.

    ```bash theme={null}
    lua test preprocessor --name handoff-gate --input '{"message":"hello","channel":"web"}' --ci
    ```

    ```text Output theme={null}
    ✅ Selected preprocessor: handoff-gate
    …
    Action: BLOCK
    Response: A teammate has your conversation and will reply here.
    ```

    Your user ID is `auth.userId` in `lua status --json --ci` and `metadata.userId` on skill log entries (`lua logs --type skill --limit 1 --json --ci`). The webhook clears the flag; the local run skips the signature check, so this is also how you clear your own flag if the signed request fails. Leave `message` out locally: `user.send()` under `lua test` throws `Failed to send message` without a direct-channel conversation, after `unset` has already cleared the flag.

    ```bash theme={null}
    lua test webhook --name resume-handoff --input '{"body":{"userId":"<<YOUR_USER_ID>>"}}' --ci
    ```

    ```text Output theme={null}
    ✅ Selected webhook: resume-handoff
    …
    Webhook returned: Object — fields: resumed
    Output:
    { resumed: true }
    ```

    Rerun the preprocessor test: `Action: PROCEED`.
  </Step>

  <Step title="Release it">
    `lua push` uploads a version and changes nothing for end users; `lua version create` snapshots the agent; `lua version promote <n>` makes that snapshot live and is also the rollback path ([Release an agent to production](/ship/releasing)).

    ```bash theme={null}
    lua push all --ci --force
    lua version create --ci -m "Human handoff"
    lua version promote <n>
    ```

    `lua version create` prints `✓ Created v<n> (staged)`; in a script, `n=$(lua version list --limit 1 --json --ci | jq -r '.[0].version')` then `lua version promote "$n"`. `lua push all` exits 0 even when a primitive fails; check its output for `component(s) failed to push`.

    <Warning>
      The gate applies to every conversation from the promote on. A flag set during testing persists until the webhook clears it: `User` data is shared between sandbox and production.
    </Warning>
  </Step>

  <Step title="Verify">
    Ask for a person, then send a second message.

    ```bash theme={null}
    lua chat -e production -m "I need to speak to a person about a refund"
    lua chat -e production -m "Hello?"
    ```

    The first reply comes from the model after it calls `request_human`; the handoff email arrives at `HANDOFF_EMAIL`. The second reply is the gate's text (`lua logs --type preprocessor --limit 5` shows the blocked turn). Then call the webhook at the URL from the email, signing the exact bytes you send.

    ```bash theme={null}
    BODY='{"userId":"<<YOUR_USER_ID>>","message":"Thanks for waiting. Your refund is on its way."}'
    SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'replace-with-a-long-random-string' | sed 's/^.* //')
    curl -X POST "https://webhook.heylua.ai/<<YOUR_AGENT_ID>>/resume-handoff" -H 'Content-Type: application/json' -H "x-lua-signature: sha256=$SIG" -d "$BODY"
    ```

    The closing message arrives in the chat, and the next `Hello?` is answered by the model again.
  </Step>
</Steps>

## Options you may need

### Notify a teammate another way

`Channels.send({ channel: 'whatsapp', to: { phoneNumber }, text })` or `channel: 'sms'` reaches a number that has never written to the agent; store it in an environment variable. `Team.findMember('Dana')` returns organization members matching a name with the channel handles they have shared.

## If it isn't working

<AccordionGroup>
  <Accordion title="The model keeps answering after the user asked for a person">
    **Cause** The tool was never called, so the flag was never set. **Fix** Make the skill `context` explicit about when to call `request_human`; `lua logs --type skill --limit 5` shows whether it ran.
  </Accordion>

  <Accordion title="No email reaches the team">
    **Cause** `HANDOFF_EMAIL` is unset in the environment the chat runs in, or the agent has no email channel: `No email channel configuration found. Ensure the agent has an email channel linked.` **Fix** Check `lua env production --list` (or `sandbox`) and link an email channel.
  </Accordion>

  <Accordion title="The webhook returns resumed: false">
    **Cause** `User.get(userId)` found nobody: the body did not carry the Lua user ID. **Fix** Use the ID from the handoff email, or look the end user up with `User.get({ email })` or `User.get({ phone })`.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="About processors" href="/concepts/processors">Block, proceed, priority, and failure handling.</Card>
  <Card title="User reference" href="/reference/sdk/user">`get`, `update`, `unset`, `send`, and the profile fields.</Card>
  <Card title="Handle a webhook" href="/build/handle-a-webhook">Signing, idempotent handling, and the URL.</Card>
  <Card title="Send proactive messages" href="/build/send-proactive-messages">Outbound channels and addressing.</Card>
</Columns>
