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.
What you’ll build
Section titled “What you’ll build”- Wait for the next message to the Agent Account from a specific sender.
- Try to extract the code with a regex first.
- Fall back to an LLM if the regex doesn’t hit.
- Return the extracted code (or a timeout) to the caller.
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.
Keep in mind
Section titled “Keep in mind”- 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.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.
What’s next
Section titled “What’s next”- Sign an agent up for a third-party service — the companion recipe
- Agent Accounts overview — product overview
- Policies, Rules, and Lists — constrain inbound so only expected senders reach the agent
- Using webhooks with Nylas — signature verification, retries, and signing