Skip to content
Skip to main content

How to extract an OTP or 2FA code from an agent's inbox

Many services verify a signup or a 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 whatever’s orchestrating the login, and move on.

This recipe assumes you already have an Agent Account and a message.created webhook subscribed. If not, start with the signup-for-a-service recipe — it walks through provisioning and webhook setup.

  1. Wait for the next message to the Agent Account from a specific sender.
  2. Try to extract the code with a regex first.
  3. Fall back to an LLM if the regex doesn’t hit.
  4. Return the extracted code (or a timeout) to the caller.

The webhook fires on every inbound. Filter down to the one that actually carries the code.

// Node.js / Express handler sketch
app.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);
});

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);
}

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.

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.

  • Expect the code to expire. Most services expire OTPs in 5–15 minutes. If your agent is slow, the code will be stale by the time you try it. Add a freshness check (message.date within 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.