Sooner or later, automation that logs into websites or creates accounts reaches a screen that says "we sent a code to your email". Two kinds of callers hit that screen. A headless browser script, written in Playwright or Puppeteer, knows exactly which click sends the code. An AI agent driving a browser decides at runtime what to click and when to look for mail. The email side is the same for both, but where the wait lives and how you pick its time window differ, and getting that wrong is what makes these flows flaky.

First, which kind of code is it?

"OTP" covers three different mechanisms, and only one of them involves email.

  • Emailed codes and links. The site sends a message, and something has to receive it and pull the code or link out. This post is about this case.
  • Authenticator-app codes (TOTP, RFC 6238). Nothing is sent. The code is computed from a shared secret and the current time, so automation needs the secret, not an inbox. Browser Use accepts a TOTP secret in sensitive_data under a key ending in bu_2fa_code, and Lumbox's credential vault returns a current code from a stored secret through POST /v1/credentials/totp.
  • SMS codes. These need a phone number and are outside what an email API can do.

Headless scripts: the script owns the timing

A script knows the moment it triggers the email, so it can open the wait with a precise window. The example below is a passwordless login that runs every day against the same long-lived inbox, which is the case where a sloppy window hurts most. Yesterday's code is still sitting in that inbox.

// login.mjs  (Node 18+, "npm i playwright")
import { chromium } from "playwright";

const API = "https://api.lumbox.co/v1";
const headers = { "X-API-Key": process.env.LUMBOX_API_KEY };
const INBOX_ID = process.env.LUMBOX_INBOX_ID;

async function waitForCodeEmail(since, seconds = 90) {
  const q = new URLSearchParams({
    has_otp: "true",
    from: "example.com",
    since: since.toISOString(),
    timeout: String(seconds),
  });
  const res = await fetch(`${API}/inboxes/${INBOX_ID}/wait?${q}`, {
    headers,
    signal: AbortSignal.timeout((seconds + 10) * 1000),
  });
  if (res.status === 408) return null;
  if (!res.ok) throw new Error(`wait ${res.status}: ${await res.text()}`);
  return res.json();
}

const isUsable = (email) =>
  email && !(email.parsed.code_expiry && Date.parse(email.parsed.code_expiry) < Date.now());

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://app.example.com/login");
await page.fill("#email", process.env.LUMBOX_ADDRESS);

let since = new Date(Date.now() - 5_000);
await page.click("#send-code");
let email = await waitForCodeEmail(since);

if (!isUsable(email)) {
  since = new Date(Date.now() - 5_000);
  await page.click("#resend-code");
  email = await waitForCodeEmail(since);
}
if (!isUsable(email)) throw new Error("no usable login code after one resend");

await page.fill("#code", email.parsed.otp_codes[0]);
await page.click("#verify");
await browser.close();

A few choices in that script are deliberate.

It uses /wait with has_otp=true, not /otp. The /otp shortcut only returns codes from emails whose keyword-based category is verification. A login-code email that says "Your sign-in code is 482913", without the words "verify" or "verification" anywhere, gets a parsed code and a general category, and /otp never returns it. /wait?has_otp=true matches any message with an extracted code.

The window is explicit. since is taken just before the click that sends the email, minus a few seconds for clock drift. Leave it out on /wait and the server only considers mail arriving after the request starts, which can miss a fast delivery. On /otp, leaving it out means "the last five minutes", which in a reused inbox can return the previous login's code.

Expiry is checked before typing. When an email says "expires in 10 minutes", the parser turns that into an absolute timestamp in parsed.code_expiry. A code that has already expired, or a wait that timed out, triggers one resend with a fresh window rather than a blind retry.

AI agents: the harness owns the timing

An agent clicks "send code", then spends a model step deciding what to do next, and the email can arrive during that step. The agent has no reliable timestamp to offer either. The fix is to let the harness choose the window before the agent starts and expose a tool where the model supplies only what it actually knows, which site it is dealing with:

export function makeVerificationTool(inboxId) {
  const startedAt = new Date(Date.now() - 10_000);

  return async function waitForVerification({ sender }) {
    const q = new URLSearchParams({
      from: sender.toLowerCase(),
      since: startedAt.toISOString(),
      timeout: "90",
    });
    const res = await fetch(`${API}/inboxes/${inboxId}/wait?${q}`, {
      headers,
      signal: AbortSignal.timeout(100_000),
    });
    if (res.status === 408) return { found: false };
    const email = await res.json();
    const p = email.parsed;
    return {
      found: true,
      subject: email.subject,
      otp_codes: p.otp_codes,
      verification_links: p.verification_links,
      magic_links: p.magic_links,
      requires_browser: p.requires_browser,
      code_expiry: p.code_expiry,
    };
  };
}

Register the returned function as a tool in whatever framework runs the agent, with a description like "wait for the email from this site and return its codes and links". Three things make it agent-friendly. The window starts before the agent's first step, so a slow model cannot miss the message. The sender filter, a substring match on the lowercased address, keeps unrelated mail out. And the result holds parsed fields only, so no email body text reaches the model's context. If you use the Lumbox MCP server instead, its get_otp tool looks back five minutes by default, which covers the same gap, with the category condition described above.

Codes, verification links and magic links

The parser fills three separate arrays. verification_links holds URLs containing words like verify, confirm, activate or validate, or a token=, code= or key= parameter with a long value. magic_links holds passwordless login URLs. requires_browser is true when a verification link looks like it leads to a CAPTCHA or challenge page. Whether a plain HTTP GET on a verification link is enough depends on the site. Many links only work in the browser session that started the signup, so opening them with page.goto(), or telling the agent to navigate there, is the safer default.

What the extraction does not catch

Parsing is done with regular expressions over the subject and plain-text body, not by a language model. It extracts 4 to 8 digit codes near words like code, PIN or verification, and a number alone on its own line. It does not extract codes that contain letters, or digits split by a space or hyphen, such as "482 913". When otp_codes comes back empty for a site that sends those formats, the email's text_body is in the same /wait response for your own pattern. The full list of patterns, with phrasings that pass and fail, is in how OTP extraction works.

Running many of these in parallel needs one inbox per run so codes cannot cross between sessions. The plan's live inbox limit (3 on Free up to 250 on Scale) sets how many can run at once. The Playwright guide shows the per-test version, the MCP docs cover agent setup, and the OTP verification overview summarizes the endpoints.