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

# Handle two-factor authentication

> When a login or signup asks for a one-time password or confirmation link, let the agent request it through a prebuilt custom tool.

export const TwoFactorAuth = () => {
  const stroke = {
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.75,
    strokeLinecap: "round",
    strokeLinejoin: "round"
  };
  const S = c => ({
    className: c,
    ...stroke
  });
  const icons = {
    agent: c => <svg viewBox="0 0 24 24" {...S(c)}><path d="M12 8V4H8" /><rect width="16" height="12" x="4" y="8" rx="2" /><path d="M2 14h2M20 14h2M15 13v2M9 13v2" /></svg>,
    lock: c => <svg viewBox="0 0 24 24" {...S(c)}><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
  };
  const Card = ({icon, title, sub, children}) => <div className="flex shrink-0 flex-col self-center rounded-xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
      <div className="flex items-center gap-2.5">
        <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">{icon("h-5 w-5")}</span>
        <div>
          <div className="whitespace-nowrap text-base font-semibold leading-6 text-zinc-900 dark:text-zinc-100">{title}</div>
          <div className="whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400">{sub}</div>
        </div>
      </div>
      {children}
    </div>;
  const Arrow = ({top, bottom, dir = "right"}) => <div className="flex flex-col items-stretch justify-center gap-1 px-4 text-center text-xs leading-4 text-zinc-500 dark:text-zinc-400">
      {top && <span className="whitespace-nowrap">{top}</span>}
      <div className={`${dir === "left" ? "flex flex-row-reverse items-center text-zinc-400 dark:text-zinc-600" : "flex items-center text-zinc-400 dark:text-zinc-600"}`}>
        <span className="h-px flex-1 bg-current" />
        <svg className="-ml-px h-3 w-2 shrink-0" viewBox="0 0 8 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={dir === "left" ? {
    transform: "scaleX(-1)",
    marginLeft: 0,
    marginRight: -1
  } : undefined}>
          <path d="M1 1.5 6 6l-5 4.5" />
        </svg>
      </div>
      {bottom && <span className="whitespace-nowrap">{bottom}</span>}
    </div>;
  const Chip = ({children}) => <span className="whitespace-nowrap rounded-md bg-zinc-100 px-2 py-0.5 font-mono text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">{children}</span>;
  return <div className="not-prose my-8 overflow-x-auto">
      <div className="flex min-w-[620px] items-stretch justify-center">
        <Card icon={icons.agent} title="Agent" sub="login asks for a code">
          <div className="mt-4 flex flex-col items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
            <Chip>request_otp</Chip>
            <div>Never guesses; waits for one value</div>
          </div>
        </Card>

        <div className="flex min-w-[200px] flex-1 flex-col justify-center gap-6">
          <Arrow top="prompt, kind, source" />
          <Arrow bottom="code or link" dir="left" />
        </div>

        <Card icon={icons.lock} title="Your handler" sub="runs on your machine">
          <div className="mt-4 flex flex-col items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
            <div className="flex gap-1.5">
              <Chip>stdin</Chip>
              <Chip>IMAP inbox</Chip>
              <Chip>custom</Chip>
            </div>
            <div>Inbox and secrets stay local</div>
          </div>
        </Card>
      </div>
    </div>;
};

<TwoFactorAuth />

Sites that protect a login with email codes, SMS codes, or confirmation links send a value the agent cannot invent. The SDKs ship a prebuilt [custom tool](/agents-api/custom-tools) for that moment: the agent calls `request_otp`, your process resolves the code or link, and the run continues with the single value.

Pass `otp_tool` / `otpTool` in `tools` the same way you would any other custom tool. Pick the handler that matches where the code arrives:

| Where the code arrives                            | Use                                         | Best for                         |
| ------------------------------------------------- | ------------------------------------------- | -------------------------------- |
| Anywhere, you are at the keyboard                 | [Interactive prompt](#prompt-interactively) | Local runs and debugging         |
| An inbox you control                              | [IMAP handler](#read-the-code-from-email)   | Unattended runs                  |
| Slack, SMS provider, any other source             | [Custom handler](#supply-a-custom-handler)  | Anything your code can reach     |
| An authenticator app with the secret in 1Password | [Vault](#authenticator-apps-via-a-vault)    | TOTP without exposing the secret |

## Prompt interactively

The default handler is enough for local runs: when the agent hits a 2FA step, your terminal asks for the code or link.

<CodeGroup>
  ```python Python theme={"system"}
  from hai_agents import Client
  from hai_agents_tools import otp_tool

  client = Client()
  result = client.run_session(
      agent="h/web-surfer-flash",
      messages="Log in to example.com and summarize the inbox.",
      tools=[otp_tool()],
  )
  print(result.answer)
  ```

  ```typescript TypeScript theme={"system"}
  import { HaiAgentsClient, otpTool } from "hai-agents";

  const client = new HaiAgentsClient();
  const result = await client.runSession({
    agent: "h/web-surfer-flash",
    messages: "Log in to example.com and summarize the inbox.",
    tools: [otpTool()],
  });
  console.log(result.answer);
  ```
</CodeGroup>

## Read the code from email

For unattended runs, hand the tool an IMAP handler. It polls unread mail (newest first), extracts a code or confirmation link, marks that message read so a retry cannot reuse a stale code, and returns only that value to the agent. For Gmail or Google Workspace, use an [app password](https://support.google.com/accounts/answer/185833).

<CodeGroup>
  ```python Python theme={"system"}
  import os

  from hai_agents import Client
  from hai_agents_tools import imap_otp_handler, otp_tool

  handler = imap_otp_handler(
      host="imap.gmail.com",
      username="agent-inbox@gmail.com",
      password=os.environ["GMAIL_APP_PASSWORD"],
      sender="no-reply@example.com",  # optional: only mail from this address
  )

  client = Client()
  result = client.run_session(
      agent="h/web-surfer-flash",
      messages="Log in to example.com and check for new notifications.",
      tools=[otp_tool(handler)],
  )
  print(result.answer)
  ```

  ```typescript TypeScript theme={"system"}
  import { HaiAgentsClient, imapOtpHandler, otpTool } from "hai-agents";

  // Optional deps for the IMAP handler: npm install imapflow mailparser
  const handler = imapOtpHandler({
    host: "imap.gmail.com",
    username: "agent-inbox@gmail.com",
    password: process.env.GMAIL_APP_PASSWORD!,
    sender: "no-reply@example.com", // optional: only mail from this address
  });

  const client = new HaiAgentsClient();
  const result = await client.runSession({
    agent: "h/web-surfer-flash",
    messages: "Log in to example.com and check for new notifications.",
    tools: [otpTool({ handler })],
  });
  console.log(result.answer);
  ```
</CodeGroup>

Like every custom tool, the handler runs in your process: IMAP credentials never leave your machine, and the agent only receives the extracted code or link. It never sees mailbox contents, subjects, or senders.

Useful IMAP options:

| Option                         | Default             | Role                                                                      |
| ------------------------------ | ------------------- | ------------------------------------------------------------------------- |
| `sender`                       | unset               | Only consider messages from this address                                  |
| `timeout_s` / `timeoutMs`      | 2 minutes           | Give up (tool error to the agent) after this long                         |
| `max_age_s` / `maxAgeMs`       | 15 minutes          | Ignore unread mail older than this                                        |
| `code_pattern` / `codePattern` | built-in heuristics | Override extraction. First capture group (or the whole match) is the code |

## Supply a custom handler

Any function that takes the agent's request and returns a string works: prompt in Slack, call an inbox API, read SMS from a provider, and so on. Handlers may be sync or async.

<CodeGroup>
  ```python Python theme={"system"}
  from hai_agents_tools import OtpRequest, otp_tool

  def from_slack(request: OtpRequest) -> str:
      # request.prompt, request.kind ("code" | "link"), request.source
      return slack.ask_user(request.prompt)

  tools = [otp_tool(from_slack)]
  ```

  ```typescript TypeScript theme={"system"}
  import { otpTool, type OtpRequest } from "hai-agents";

  async function fromSlack(request: OtpRequest): Promise<string> {
    // request.prompt, request.kind ("code" | "link"), request.source
    return slack.askUser(request.prompt);
  }

  const tools = [otpTool({ handler: fromSlack })];
  ```
</CodeGroup>

## What the agent sends

The tool's input schema is fixed. The agent fills:

| Field    | Required | Meaning                                                                       |
| -------- | -------- | ----------------------------------------------------------------------------- |
| `prompt` | yes      | Human-readable ask, e.g. "Enter the 6-digit code sent to j\*\*\*@example.com" |
| `kind`   | no       | `"code"` (default) or `"link"` for a full confirmation URL                    |
| `source` | no       | Where it was sent, e.g. `"email"`, `"sms"`, `"authenticator app"`             |

Your handler should return a non-empty string. Empty values fail as a tool error so the agent can retry or stop cleanly.

## Authenticator apps via a vault

If the site uses a TOTP authenticator and the secret already lives in [1Password](https://developer.1password.com/), bind a [vault](/agents-api/vaults/overview) to the browser instead. When the page matches a stored item, the session offers [`fill_secret_at`](/agents-api/browser/configuration#actions) with `totp` and injects the code without putting it in the agent's context.

|                 | `otp_tool`                          | Vault                        |
| --------------- | ----------------------------------- | ---------------------------- |
| Code arrives    | Out of band: email, SMS, magic link | Generated from a TOTP secret |
| Who resolves it | Your handler, in your process       | The session, from 1Password  |
| Agent sees      | The single code or link             | Nothing                      |

## Next steps

<CardGroup cols={2}>
  <Card title="Vaults" icon="key" href="/agents-api/vaults/overview">
    Fill passwords and TOTP codes from 1Password.
  </Card>

  <Card title="Custom tools" icon="wrench" href="/agents-api/custom-tools">
    The general mechanism behind the OTP tool.
  </Card>
</CardGroup>
