Many services verify a signup or login with a short-lived code instead of a confirmation link — a six-digit OTP, a 2FA challenge, a magic number the user has to paste back. If the agent owns the mailbox the code lands in, it can pick the code up, return it to the orchestrator, and move on. No human inbox in the middle.
This recipe assumes you already have an Agent Account with a message.created webhook subscribed. If you don’t, start with Sign an agent up for a third-party service — it walks through provisioning and webhook setup.
Match the right message
Section titled “Match the right message”The webhook fires on every inbound. Filter down to the one that actually carries the code.
// Node.js / Express handler sketchapp.post("/webhooks/otp", async (req, res) => { res.status(200).end();
const event = req.body; if (event.type !== "message.created") return;
const msg = event.data.object; if (msg.grant_id !== AGENT_GRANT_ID) return;
const sender = msg.from?.[0]?.email ?? ""; const subject = msg.subject ?? "";
// Two signals the message is the one you want: // 1. sender domain matches the service you're authenticating against // 2. subject mentions "code", "verification", or similar const senderMatches = sender.endsWith("@no-reply.example.com"); const subjectLooksRight = /code|verif|one.?time|passcode/i.test(subject); if (!senderMatches || !subjectLooksRight) return;
await handleOtp(msg.id);});Extract with regex first
Section titled “Extract with regex first”Most OTP emails follow one of a few shapes: a standalone 4–8 digit number, or a code after a label like “Your code is:” or “One-time code:”. A handful of regex patterns cover the vast majority of services.
async function handleOtp(messageId) { const resp = await fetch( `https://api.us.nylas.com/v3/grants/${AGENT_GRANT_ID}/messages/${messageId}`, { headers: { Authorization: `Bearer ${NYLAS_API_KEY}` } }, ); const { data: message } = await resp.json();
// Strip HTML so the regex sees the plain text, not inline style / hidden tracking pixels. const plaintext = stripHtml(message.body);
const patterns = [ /(?:code|passcode|one[\s-]?time)[^\d]{0,20}(\d{4,8})/i, // "Your code is: 123456" /\b(\d{6})\b/, // bare 6-digit /\b(\d{4,8})\b/, // bare 4–8 digit (last resort) ];
for (const p of patterns) { const match = p.exec(plaintext); if (match) { return returnCode(match[1]); } }
// Didn't match — fall back to the LLM. return extractWithLlm(plaintext);}Fall back to an LLM
Section titled “Fall back to an LLM”Some services wrap the code in noisy marketing layouts that regex can’t handle. Passing the message body to a small LLM with a focused prompt picks up the rest.
async function extractWithLlm(plaintext) { const response = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: "You extract one-time verification codes from email bodies. " + "Respond with JSON only: {\"code\": \"<the code>\"} or " + "{\"code\": null} if no code is present.", }, { role: "user", content: plaintext.slice(0, 4000) }, ], response_format: { type: "json_object" }, });
const parsed = JSON.parse(response.choices[0].message.content); if (parsed.code) return returnCode(parsed.code);}The prompt stays narrow on purpose. Don’t ask the LLM to “understand” the email; ask it for one thing, in one shape, and bail otherwise.
Return the code to the caller
Section titled “Return the code to the caller”Whatever triggered the signup or login is blocked waiting for the code. The simplest pattern is a promise-based registry keyed by some correlation value — a session ID, an expected sender, a run ID.
const pending = new Map(); // correlationKey -> { resolve, reject, timer }
export function awaitCode(correlationKey, timeoutMs = 60_000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(correlationKey); reject(new Error("OTP timeout")); }, timeoutMs);
pending.set(correlationKey, { resolve, reject, timer }); });}
function returnCode(code, correlationKey = "default") { const waiter = pending.get(correlationKey); if (!waiter) return; clearTimeout(waiter.timer); waiter.resolve(code); pending.delete(correlationKey);}In practice, use a real queue or pub/sub in production — webhook handlers run on short-lived processes, and an in-memory Map doesn’t survive a restart.
Things to know
Section titled “Things to know”- Expect the code to expire. Most services expire OTPs in 5–15 minutes. If the agent is slow, the code will be stale by the time you try it. Add a freshness check (
message.datewithin the last few minutes) before returning. - Watch for multiple codes. If an earlier attempt left a stale OTP in the inbox and the service sends a new one, your regex might grab the wrong one. Always sort matches by message timestamp, newest first.
- Don’t log codes. OTPs are credentials. Log that the agent received and returned one; don’t log the value itself.
- Rate-limit aggressively. A tight loop that keeps requesting codes looks like an attack to the other side and will get the agent’s address blocked. Limit retries, back off on failure.
- Some services deliberately vary format. Banking and enterprise providers sometimes rotate code formats (6 digits, 8 digits, alphanumeric) across sessions. Keep the regex patterns permissive and rely on the LLM fallback when shape changes.
Next steps
Section titled “Next steps”- Sign an agent up for a third-party service — the companion recipe that gets the code in the door
- Handle email replies in an agent loop — for verification flows that follow up with another message
- Prevent duplicate agent replies — stop a redelivered webhook from re-using a stale code
- Policies, Rules, and Lists — constrain inbound so only expected senders reach the agent
- Using webhooks with Nylas — signature verification, retries, and signing