An agent that needs an account on an external service has to do five things in order: get an email address, fill the signup form, receive the verification email, act on the code or link inside it, and keep what it needs to log in again. This post is one working implementation of that flow in TypeScript with Playwright and the Lumbox REST API, followed by the places it breaks and why.

It covers a single registration done carefully. Running registrations as a pipeline with retries, state and concurrency is covered in the complete registration guide.

Before writing any code: is this signup yours to automate?

Automated registration is fine for your own product (testing it, seeding staging, smoke-testing production), for services whose terms allow accounts created by software, and for the one account a person decided their agent should have. It is not fine for creating many accounts to multiply free trials, to dodge per-account limits, or on services that forbid automated signups. Lumbox enforces its own side of this: signup throttles, blocked brand-impersonation names on shared domains, and suspension for abuse. The code below creates one account per call on purpose.

The code

import { chromium } from "playwright";
import { randomBytes } from "node:crypto";

const API = "https://api.lumbox.co/v1";
const headers = {
  "X-API-Key": process.env.LUMBOX_API_KEY!,
  "Content-Type": "application/json",
};

async function lumbox(path: string, init: RequestInit = {}) {
  const res = await fetch(`${API}${path}`, { ...init, headers });
  if (res.status === 408) return null; // long-poll ended with no match
  if (!res.ok) throw new Error(`Lumbox ${res.status}: ${await res.text()}`);
  return res.json();
}

export async function register(signupUrl: string, senderDomain: string) {
  const inbox = await lumbox("/inboxes", {
    method: "POST",
    body: JSON.stringify({ metadata: { site: senderDomain } }),
  });
  const password = randomBytes(18).toString("base64url");

  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();
  try {
    await page.goto(signupUrl);
    await page.getByLabel("Email").fill(inbox.address);
    await page.getByLabel("Password").fill(password);
    await page.getByRole("button", { name: "Sign up" }).click();

    const q = new URLSearchParams({
      from: senderDomain,
      since: inbox.created_at,
      timeout: "90",
    });
    const email = await lumbox(`/inboxes/${inbox.id}/wait?${q}`);
    if (!email) throw new Error("no email from " + senderDomain + " in 90s");

    const { otp_codes, verification_links, requires_browser } = email.parsed;
    if (requires_browser) throw new Error("verification needs a human (captcha)");

    if (otp_codes.length > 0) {
      await page.getByLabel("Verification code").fill(otp_codes[0]);
      await page.getByRole("button", { name: "Verify" }).click();
    } else {
      const link = verification_links.find(
        (u: string) => new URL(u).hostname.endsWith(senderDomain),
      );
      if (!link) throw new Error("no code or link; inspect email.text_body");
      await page.goto(link);
    }

    await context.storageState({ path: `state/${inbox.id}.json` });
    return { inboxId: inbox.id, address: inbox.address, password };
  } finally {
    await browser.close();
  }
}

The labels ("Email", "Sign up", "Verification code") are placeholders for whatever the target form uses. Playwright's getByLabel and getByRole locators survive markup changes better than CSS selectors on name attributes, and they fail loudly when the label text changes.

Why each piece is there

One inbox per registration

POST /v1/inboxes returns an id, an address on trylumbox.com and a created_at timestamp. Leaving name out lets Lumbox generate the local part; on a shared domain a chosen name can already be taken (409) or be refused as reserved or brand-like. The metadata object is yours, and it comes back when you fetch the inbox later.

since closes a race you would otherwise hit

/wait only returns mail received at or after since, and when you omit it, since is the moment the request arrives. The code above clicks "Sign up" and only then starts waiting. A fast sender can deliver in between, and the wait then sits for 90 seconds looking for an email that is already in the inbox. Passing the inbox's own created_at covers everything since the address existed, measured on Lumbox's clock rather than yours.

from filters out everything else

from is a substring match on the sender address, so example.com matches no-reply@mail.example.com. Without it, the first email of any kind satisfies the wait. That includes the welcome message Lumbox seeds into an organisation's first inbox, which would otherwise be picked up as the verification email and fail the flow.

Code or link, decided by the email

The parsed email carries otp_codes and verification_links already extracted, so the agent does not need to know in advance which method the service uses. Codes are 4 to 8 digit numbers found in the subject and plain-text body; links are URLs containing words like verify, confirm or activate, or a token= style parameter. The link is filtered to the sender's own domain because a verification email can contain other URLs that match the same pattern, such as a sign-in page or a click-tracking redirect.

requires_browser is a stop sign

Lumbox sets requires_browser when a verification link points at a captcha or challenge URL. An agent should not try to get past that. Hand it to a person or drop the registration.

Keep the inbox

The function does not delete the inbox, deliberately. Lumbox inboxes do not expire, so password resets, new-device codes and account notices will keep arriving at that address for as long as the account exists. Store inboxId, address and the password together in a secret store, and store the storageState file (cookies and local storage) so later runs can skip logging in. If the registration fails, delete the inbox with DELETE /v1/inboxes/:id; failed attempts count against your plan's inbox limit otherwise.

Failure modes worth handling

  • 408 after 90 seconds. The email never matched. Check the sender domain first, then look at GET /v1/inboxes/:id/emails to see what did arrive; it hides spam by default, so add include_spam=true.
  • A code the parser misses. Alphanumeric codes such as X7K-29Q are not in otp_codes. The email's text_body is there for the agent to read.
  • An expired code. When the email says "expires in 10 minutes", parsed.code_expiry holds the computed time. If the form rejects the code, click the site's resend button and wait again with a fresh since.
  • The form changed. The locators throw. That is the right outcome; a registration that half-completes silently is harder to clean up than one that fails.

Where the AI agent fits

Nothing above needs a language model, and the email half should stay that way: waiting for a message and reading a parsed field is deterministic. The agent's value is on the browser side, finding the right fields on a form it has never seen. A common split is to let the agent drive the page while the inbox work runs as plain tool calls. Lumbox's MCP server exposes those as tools (create_inbox, wait_for_email, get_otp), so an MCP-capable agent can call them directly instead of going through this function.

For a deeper look at the browser side, the Playwright OTP guide covers locator strategies for verification forms, and the inboxes docs list every /wait filter.