Browserbase and Stagehand
Use Stagehand v4 with Browserbase and a Lumbox inbox for email verification.
Browserbase and Stagehand
This guide connects Stagehand v4 on Browserbase to a Lumbox inbox. It discovers form controls with stagehand.observe(), then fills the returned selectors directly. Replace the signup instructions and success condition for a site you control or are authorized to automate.
By Kumar
Install and configure
The example was checked with @browserbasehq/stagehand 4.1.0. Stagehand v4 supports Browserbase's Model Gateway. This example uses the Browserbase managed model path, so it passes the Browserbase key to browserbase.launch() and does not configure local model credentials. Local browser setups require a model provider and API key.
npm install @browserbasehq/stagehand@4.1.0 tsx@4.23.15 typescript@7.0.2export LUMBOX_API_KEY=ak_your_key
export BROWSERBASE_API_KEY=your_browserbase_key
export SIGNUP_URL=https://your-site.example/signup
export EXPECTED_SENDER=accounts@your-site.example
export SUCCESS_SELECTOR='[data-testid="account-dashboard"]'Set EXPECTED_SENDER to a substring of the verification sender. Lumbox uses it as a convenience filter, not sender authentication. Run as npx tsx browserbase.ts.
Create and verify an account
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
};
const apiKey = required("LUMBOX_API_KEY");
const browserbaseApiKey = required("BROWSERBASE_API_KEY");
const signupUrl = required("SIGNUP_URL");
const expectedSender = required("EXPECTED_SENDER");
const successSelector = required("SUCCESS_SELECTOR");
const apiUrl = process.env.LUMBOX_API_URL ?? "https://api.lumbox.co";
async function createInbox() {
const response = await fetch(`${apiUrl}/v1/inboxes`, {
method: "POST",
headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!response.ok) throw new Error(`Lumbox inbox creation failed with HTTP ${response.status}`);
const inbox = await response.json() as { id: string; address: string };
if (!inbox.id || !inbox.address) throw new Error("Lumbox returned an incomplete inbox response");
return inbox;
}
async function getOtp(inboxId: string, since: string): Promise<string> {
const query = new URLSearchParams({ timeout: "30", since, from: expectedSender });
const response = await fetch(`${apiUrl}/v1/inboxes/${encodeURIComponent(inboxId)}/otp?${query}`, {
headers: { "X-API-Key": apiKey },
});
if (response.status === 408) throw new Error("No matching verification code arrived within 30 seconds");
if (!response.ok) throw new Error(`Lumbox OTP request failed with HTTP ${response.status}`);
const result = await response.json() as { code?: string };
if (typeof result.code !== "string" || result.code.length === 0) throw new Error("Lumbox returned no verification code");
return result.code;
}
function oneObservedSelector(actions: Array<{ selector?: string }>, field: string): string {
if (actions.length !== 1 || !actions[0]?.selector) {
throw new Error(`Expected exactly one ${field} control from Stagehand observe()`);
}
return actions[0].selector;
}
async function main() {
const inbox = await createInbox();
const browser = await browserbase.launch({ apiKey: browserbaseApiKey });
let stagehand: Awaited<ReturnType<typeof Stagehand.create>> | undefined;
try {
stagehand = await Stagehand.create({ browser });
const [page] = await browser.context.pages();
if (!page) throw new Error("Browserbase did not provide an active page");
await page.goto(signupUrl);
const emailActions = (await stagehand.observe("find the signup email address input field")).data;
const emailSelector = oneObservedSelector(emailActions, "email");
await page.locator(emailSelector).fill(inbox.address);
const submitActions = (await stagehand.observe("find the signup form submit button")).data;
const submitSelector = oneObservedSelector(submitActions, "signup submit");
const since = new Date().toISOString();
await page.locator(submitSelector).click();
const code = await getOtp(inbox.id, since);
const otpActions = (await stagehand.observe("find the email verification code input field")).data;
const otpSelector = oneObservedSelector(otpActions, "verification code");
await page.locator(otpSelector).fill(code);
const verifyActions = (await stagehand.observe("find the verification form submit button")).data;
const verifySelector = oneObservedSelector(verifyActions, "verification submit");
await page.locator(verifySelector).click();
const successVisible = await page.waitForSelector(successSelector, { state: "visible", timeout: 30_000 });
if (!successVisible) throw new Error("Configured success selector did not become visible");
console.log("Signup completed and the configured success selector is visible.");
} finally {
try {
await stagehand?.close();
} finally {
await browser.close();
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "Signup failed");
process.exitCode = 1;
});The email and code controls are discovered from the page, and the returned selectors are used with Playwright locators. The example stops if an observation is missing or ambiguous. The OTP is filled directly and is never included in a natural-language prompt. Set a stable SUCCESS_SELECTOR for the completed account page.
Lumbox behavior and cleanup
The inbox address is read from the create response. Its domain follows the Lumbox account's configured default. since is captured just before the signup form is submitted and reused in the OTP query. Lumbox returns the first extracted code from the newest matching verification message as a string. It does not consume that code or establish whether the target site will accept it.
The OTP endpoint waits 30 seconds by default and accepts a timeout from 1 through 120 seconds. HTTP 408 means there was no matching code. HTTP 401 indicates an authentication problem, 403 indicates inbox access is denied, 402 during inbox creation indicates the plan inbox limit, and other failures should be inspected by status.
The finally block closes Stagehand and then closes Browserbase even if Stagehand setup or the signup fails. The inbox remains a persistent identity. Manually delete it with DELETE /v1/inboxes/:id when it is no longer needed; deletion permanently removes its emails.
Troubleshooting
- If Stagehand finds no field, wait for the form to load or adjust the observe instruction for the target page.
- If it finds multiple controls, narrow the instruction or make the form unambiguous instead of selecting one arbitrarily.
- If the model cannot run, confirm Browserbase Model Gateway is enabled for the account. Local browser mode needs an explicitly configured model and provider key.
- If the OTP call returns 408, confirm the sender substring and that the submitted site sends an email code.
Sources checked
- Stagehand v4 Browser configuration
- Stagehand v4 Observe
- Stagehand v4 Quickstart
- Lumbox inbox and OTP behavior:
apps/api/src/routes/inboxes.tsandapps/api/src/utils/auth.ts