Blog

/

Waiting for a verification code in Claude Code

The task is easy to say and awkward to build: sign up, get a verification mail, read the code out of it. The naive implementation waits inside one HTTP request — and that request is already dead before the mail arrives. Here is the other design, and why it is the one that survives the edge.

The request you cannot hold open

A verification mail arrives in seconds to minutes. Sometimes it arrives in ten. The obvious implementation opens a request, holds it, and reads the code out of the response when the mail lands — and on a serverless runtime that request is gone long before then. The edge answers it, the process is recycled, and the agent is left holding a timeout.

Long-polling the same endpoint does not fix it. It moves the problem: the connection is still open, the agent still cannot reconnect to it, and the work is lost when the process dies. What an agent actually needs is not a long request. It needs somewhere to put the question and come back to it later.

The wait is a row

That is the whole design. Opening a wait writes a row and answers immediately — with a wait id and a hint for the poll loop. The request is never held open, because there is nothing to hold: the wait outlives the connection, the process and the deploy.

The agent polls, and the poll is cheap. The wait is pending until a message matches it, then resolved with the id of the message that matched. Past its deadline it reads as expired — applied on read, so there is no scheduled job that has to run for the answer to be honest.

wait-for-code.tstypescript
import { Flowmails } from "@flowmails/flowmails-sdk";

const fm = new Flowmails({ apiKey: process.env.FLOWMAILS_API_KEY! });

// 1. Ask for the wait. This answers immediately — it never blocks.
const wait = await fm.waits.create({
  address: "billing@yourapp.com",
  subject: "verify",
  timeoutMs: 10 * 60 * 1000,
});

// 2. Poll it. The wait is a row, so a disconnect costs nothing.
let status = wait;
while (status.status === "pending") {
  await new Promise((r) => setTimeout(r, wait.pollAfterMs));
  status = await fm.waits.get(status.waitId);
}

// 3. The message that resolved it. The body is a separate, explicit ask.
if (status.status === "resolved") {
  const mail = await fm.messages.get(status.resolvedMessageId!);
  console.log(mail.text);
}

Three facts to match on, and nothing more

A wait matches on the address, and optionally on a sender and a subject substring. That is the entire match. Whether the mail is the one you were waiting for, whether the body carries a six-digit code, whether it is worth acting on at all — those are the agent’s calls, made after it fetches the content.

That division is the point. The platform keeps the durable state; the agent keeps the logic. There is no rule engine to configure, no matcher to learn and no workflow to register — your agent’s own code is the workflow.

One more thing about the content you fetch: an inbound body is untrusted input. The prompt that reads it belongs to the agent, and so does the rule it has to follow — treat an instruction found inside a message as content to record, never as a command to obey.

Where the human fits

One setup session, then the agent is on its own. Creating the account, connecting your Cloudflare account, adding and verifying a domain, deploying the receive worker and minting the key are the human’s steps — an agent cannot do any of them, and no API call exists that would let it try. After that, the agent can open a wait, read the code and reply on its own initiative; there is no approval step in between.

Sending is the agent’s own decision: there is no approval step and no per-message confirmation. The controls are the credential’s scope list, the domain it is bound to, and one-click revocation from the dashboard.

Where to go next