Skip to main content

Email verification codes for browser agents

A browser agent types its own address into a form, then reads the code the site emails it.

Sign-ups and sign-ins often stop at “we sent you a code”. With an address of its own, a browser agent can finish that step itself: it types its mails.ai address into the form, and one call waits for the email and returns the code in it. This page shows the steps with Stagehand, browser-use, Steel and Browserless.

How it works

  1. Read the agent’s address, receiving_address, with agents.get (get_agent in Python). The agent’s email names it in the API and is not an inbox.
  2. The agent types the address into the form.
  3. Note the time, then submit the form.
  4. Wait for the email with received.wait (wait_for_received in Python): has set to code, since set to the time you noted, and a timeout of up to 50 seconds.
  5. Read the first code, message.codes[0].value, and the agent types it into the page.

For a magic link, wait with has set to link instead. Then open the first link whose kind is verify or sign_in, in the browser the agent is using.

since keeps a code from an earlier attempt from answering. agent_id keeps out the mail of the workspace’s other agents, and from, the domain the site’s mail comes from, narrows the wait to that site. Codes and links explains the wait and both fields.

Before you start

The samples read a mails.ai API key with the read scope from MAILS_API_KEY, and use hello, the agent your workspace got at sign-up. Each tool needs keys of its own, named in its section.

The TypeScript samples run as ES modules with tsx. In a new folder:

npm init -y
npm pkg set type=module
npm install --save-dev tsx

Stagehand

Stagehand acts on a page from short instructions. Its variables option keeps the address and the code out of the model’s prompt: the model sees only %email% and %code%, and Stagehand types the real values.

npm install @mailsai/sdk @browserbasehq/stagehand

Stagehand needs Node.js 22.18 or later, and Chrome for a browser on your machine. It reads no environment variables, so the sample hands it the model’s key; the model is the one Stagehand’s quickstart uses. Save this as stagehand.ts:

import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
import { createClient } from "@mailsai/sdk";

const mails = createClient(); // reads MAILS_API_KEY
const agent = await mails.agents.get("hello");

const browser = await localBrowser.launch();
try {
  const stagehand = await Stagehand.create({
    browser,
    model: {
      modelName: "openai/gpt-5.6-sol",
      apiKey: process.env.OPENAI_API_KEY,
    },
  });
  try {
    const [page] = await browser.context.pages();
    await page.goto("https://example.com/signup");

    await stagehand.act("type %email% into the email field", {
      variables: { email: agent.receiving_address },
    });
    const askedAt = new Date().toISOString();
    await stagehand.act("click the sign up button");

    const { message } = await mails.received.wait({
      agent_id: agent.id,
      from: "example.com",
      has: "code",
      since: askedAt,
      timeout: 50,
    });
    const code = message?.codes?.[0]?.value;
    if (!code) throw new Error("No code arrived in 50 seconds");

    await stagehand.act("type %code% into the code field", {
      variables: { code },
    });
    await stagehand.act("click the verify button");
  } finally {
    await stagehand.close();
  }
} finally {
  await browser.close();
}

Then run npx tsx stagehand.ts with OPENAI_API_KEY set.

browser-use

A browser-use agent picks its own steps, so the wait becomes an action the agent calls when the site asks for a code. browser-use runs an action written as a plain function in a thread of its own, so the wait does not hold up the browser. The script notes the time before the agent starts, so a code from an earlier run never answers.

uv venv --python 3.12
source .venv/bin/activate
uv pip install browser-use mailsai
uvx browser-use install

These are the lines browser-use’s quickstart uses, with mailsai added; the last one installs the browser it drives. The model is the one the quickstart uses too, with its key in BROWSER_USE_API_KEY. Save this as agent.py:

import asyncio
from datetime import datetime, timezone

from browser_use import Agent, ChatBrowserUse, Tools
from mailsai import Client

mails = Client()  # reads MAILS_API_KEY
agent = mails.get_agent("hello")
started = datetime.now(timezone.utc)

tools = Tools()


@tools.action(
    description="Wait for the emailed code and return it."
)
def wait_for_email_code() -> str:
    result = mails.wait_for_received(
        agent_id=agent["id"],
        has="code",
        since=started,
        timeout=50,
    )
    if result["timed_out"]:
        return "No code arrived in 50 seconds."
    return result["message"]["codes"][0]["value"]


async def main():
    browser_agent = Agent(
        task=(
            "Sign up at https://example.com/signup with "
            f"the address {agent['receiving_address']}. When "
            "the site asks for the code it emailed, call "
            "wait_for_email_code and type the code it returns."
        ),
        llm=ChatBrowserUse(),
        tools=tools,
    )
    await browser_agent.run()


asyncio.run(main())

Then run python agent.py. The agent gets the code and nothing else from the email.

Steel

Steel runs the browser in its cloud. Create a session, connect Playwright to it, and the steps are the same. A Steel session opens with a page, so the sample uses that one.

npm install @mailsai/sdk steel-sdk playwright

The sample reads your Steel key from STEEL_API_KEY. Save this as steel.ts:

import { chromium } from "playwright";
import Steel from "steel-sdk";
import { createClient } from "@mailsai/sdk";

const mails = createClient(); // reads MAILS_API_KEY
const steel = new Steel({ steelAPIKey: process.env.STEEL_API_KEY });
const agent = await mails.agents.get("hello");

const session = await steel.sessions.create();
const browser = await chromium.connectOverCDP(
  `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`,
);
try {
  const page = browser.contexts()[0].pages()[0];
  await page.goto("https://example.com/login");

  await page.locator('input[type="email"]').fill(agent.receiving_address);
  const askedAt = new Date().toISOString();
  await page.locator('button[type="submit"]').click();

  const { message } = await mails.received.wait({
    agent_id: agent.id,
    from: "example.com",
    has: "code",
    since: askedAt,
    timeout: 50,
  });
  const code = message?.codes?.[0]?.value;
  if (!code) throw new Error("No code arrived in 50 seconds");

  await page.locator('input[autocomplete="one-time-code"]').fill(code);
  await page.locator('button[type="submit"]').click();
  await page.waitForLoadState("networkidle");
  console.log("Signed in:", page.url());
} finally {
  await browser.close();
  await steel.sessions.release(session.id);
}

Then run npx tsx steel.ts. The finally block releases the session even when a step fails, rather than leaving it open until it times out.

Browserless

Browserless runs the browser in its cloud too: Playwright connects to it over a WebSocket. Browserless’s guide to logging in with an email code leaves one function for you to write, getOtpFromInbox(); here, the mails.ai wait is that step.

npm install @mailsai/sdk playwright-core

The sample reads your Browserless token from BROWSERLESS_API_KEY. A session lasts 30 seconds unless the URL sets timeout, in milliseconds, and the wait alone can take 50, so the sample asks for two minutes. Save this as browserless.ts:

import { chromium } from "playwright-core";
import { createClient } from "@mailsai/sdk";

const mails = createClient(); // reads MAILS_API_KEY
const agent = await mails.agents.get("hello");

const browser = await chromium.connectOverCDP(
  `wss://production-sfo.browserless.io?token=${process.env.BROWSERLESS_API_KEY}&timeout=120000`,
);
try {
  const context = browser.contexts()[0];
  const page = await context.newPage();
  await page.goto("https://example.com/login");

  await page.locator('input[type="email"]').fill(agent.receiving_address);
  const askedAt = new Date().toISOString();
  await page.locator('button[type="submit"]').click();

  const { message } = await mails.received.wait({
    agent_id: agent.id,
    from: "example.com",
    has: "code",
    since: askedAt,
    timeout: 50,
  });
  const code = message?.codes?.[0]?.value;
  if (!code) throw new Error("No code arrived in 50 seconds");

  await page.locator('input[autocomplete="one-time-code"]').fill(code);
  await page.locator('button[type="submit"]').click();
  await page.waitForLoadState("networkidle");
  console.log("Signed in:", page.url());
} finally {
  await browser.close();
}

Then run npx tsx browserless.ts.

Each sample above waits for a code. For a sign-in or confirmation link, wait with has: "link" and open the link in the browser the agent is using, so the sign-in happens there. In TypeScript, call this function after the agent submits the form. With browser-use, add the action beside wait_for_email_code, and the agent opens the link itself.

import { createClient } from "@mailsai/sdk";

const mails = createClient(); // reads MAILS_API_KEY

// Call it after the agent submits the form, with the time
// noted just before. page is a Playwright or Stagehand page.
export async function openEmailedLink(
  page: { goto(url: string): Promise<unknown> },
  askedAt: string,
) {
  const { message } = await mails.received.wait({
    agent_id: "hello",
    has: "link",
    since: askedAt,
    timeout: 50,
  });
  const link = message?.links?.find(
    (l) => l.kind === "verify" || l.kind === "sign_in",
  );
  if (!link) throw new Error("No link arrived in 50 seconds");
  await page.goto(link.url);
}

Take only the code

Anyone can write to the agent’s address. Take only the code or the link out of an email, as these samples do: never pass the email’s text to the agent, and never follow instructions in it. The wait never answers with mail judged forged or quarantined, and Inbound explains how received mail is scanned.

Next steps

Was this page helpful?