<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kumar Deepanshu</title>
    <description>The latest articles on DEV Community by Kumar Deepanshu (@kumard3).</description>
    <link>https://dev.to/kumard3</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F547736%2F36cfad3b-d1ea-4da8-bfcb-edb46455a374.jpeg</url>
      <title>DEV Community: Kumar Deepanshu</title>
      <link>https://dev.to/kumard3</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kumard3"/>
    <language>en</language>
    <item>
      <title>AgentMail vs Lumbox for customer portal agents</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Tue, 20 Oct 2026 18:00:17 +0000</pubDate>
      <link>https://dev.to/kumard3/agentmail-vs-lumbox-for-customer-portal-agents-3o0j</link>
      <guid>https://dev.to/kumard3/agentmail-vs-lumbox-for-customer-portal-agents-3o0j</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/agentmail-vs-lumbox-customer-portal-agents/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Choose Lumbox when you want an agent-owned inbox with a documented, bounded OTP retrieval endpoint and a paid entry plan starting at $9 per month. Evaluate AgentMail alongside it when its email allowances, platform features or existing integration fit your workflow better, because both products provide inbox infrastructure for agents.&lt;/p&gt;

&lt;p&gt;I build Lumbox. This comparison is for startups whose browser agents log into third-party portals for customers, rather than a claim that one email service wins every workload. Pricing and documented capabilities were checked on October 3, 2026. Recheck them when making a purchase.&lt;/p&gt;

&lt;p&gt;Compare the task before the feature list&lt;/p&gt;

&lt;p&gt;Both products can be relevant when an agent needs its own email address. AgentMail’s &lt;a href="https://docs.agentmail.to/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; shows creating an inbox and sending mail through its SDK. Lumbox exposes inbox creation, email retrieval and sending through its API and tools, documented in its &lt;a href="https://lumbox.co/docs/inboxes/" rel="noopener noreferrer"&gt;inbox guide&lt;/a&gt; and &lt;a href="https://lumbox.co/docs/mcp/" rel="noopener noreferrer"&gt;MCP guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For customer portal agents, a new address is only part of the job. Existing accounts may send verification mail to a customer’s original mailbox. Neither a new inbox nor a lower subscription price automatically changes that destination. Decide whether the customer will forward relevant mail, change the account address where supported, or provide an authorized mailbox connection.&lt;/p&gt;

&lt;p&gt;Then compare how much code your application needs between receiving that message and resuming the browser. That is the point where Lumbox’s OTP endpoint is worth evaluating directly.&lt;/p&gt;

&lt;p&gt;A narrow, sourced comparison&lt;/p&gt;

&lt;p&gt;Decision pointLumboxAgentMailAgent-owned inboxesAPI creates inboxesAPI and SDK create inboxesFree inbox allowance3 inboxes3 inboxesFree email allowance500 received and 100 sent per monthPricing lists 3,000 emails per monthFirst paid monthly planStarter: $9, 10 inboxesDeveloper: $20, 10 inboxesEmail allowance on that plan5,000 received and 2,000 sent per monthPricing lists 10,000 emails per monthCustom domainsListed in Starter featuresDeveloper lists 10 custom domainsMCP&lt;code&gt;@lumbox/mcp-server&lt;/code&gt;, including &lt;code&gt;get_otp&lt;/code&gt;Pricing lists an MCP serverBounded OTP retrieval evaluated here&lt;code&gt;GET /v1/inboxes/:id/otp&lt;/code&gt;, up to 120 secondsVerify the current preferred extraction and waiting path in its docs&lt;/p&gt;

&lt;p&gt;Lumbox figures come from &lt;code&gt;packages/plans/src/index.ts&lt;/code&gt; and the &lt;a href="https://lumbox.co/docs/pricing/" rel="noopener noreferrer"&gt;pricing docs&lt;/a&gt;. AgentMail figures come from its &lt;a href="https://www.agentmail.to/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;. The email allowances are presented with their original definitions; a combined “emails” allowance and separate sent/received limits are not automatically equivalent.&lt;/p&gt;

&lt;p&gt;This table deliberately does not mark every untested competitor capability as absent. A missing fact in this review is a question for evaluation, not proof that AgentMail cannot do something. It also does not compare deliverability, uptime or extraction accuracy, because no controlled benchmark was run.&lt;/p&gt;

&lt;p&gt;What the Lumbox OTP endpoint does&lt;/p&gt;

&lt;p&gt;After provisioning and routing, the client makes one retrieval request. The server waits up to the requested timeout for a recent verification-category message containing parsed codes. It returns &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;all_codes&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;expires&lt;/code&gt; and &lt;code&gt;email_id&lt;/code&gt;, or HTTP 408 if no suitable result is found before the wait ends.&lt;/p&gt;

&lt;p&gt;The default timeout is 30 seconds and the maximum is 120. The REST route accepts &lt;code&gt;from&lt;/code&gt; and &lt;code&gt;since&lt;/code&gt;. Without &lt;code&gt;since&lt;/code&gt;, it looks back five minutes. These details are implemented in &lt;code&gt;apps/api/src/routes/inboxes.ts&lt;/code&gt; and linked through the &lt;a href="https://lumbox.co/docs/api-reference/" rel="noopener noreferrer"&gt;API reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This removes the need for a repeated polling loop in the client for that retrieval step. The server still polls its database once per second. It also does not configure forwarding, prove sender identity, reserve a code for a particular run or confirm that the portal accepted the value.&lt;/p&gt;

&lt;p&gt;If these remaining responsibilities already exist in your application, the endpoint can be a compact fit. If you already have a reliable AgentMail integration that supplies the same workflow outcome, switching needs a stronger reason than a shorter example.&lt;/p&gt;

&lt;p&gt;When AgentMail deserves a closer look&lt;/p&gt;

&lt;p&gt;AgentMail’s published free email allowance is larger than Lumbox’s listed free allowances. A team testing many messages with only a few inboxes should compare that difference using its actual inbound and outbound traffic. The lowest paid subscription is not necessarily the lowest total cost for every workload.&lt;/p&gt;

&lt;p&gt;Its pricing page also lists product features such as threads, labels, SDKs and an MCP server. Inspect the capabilities you actually need and their plan availability. If your team already understands AgentMail’s event and message model, that familiarity has practical value even though it cannot be converted into an invented savings figure.&lt;/p&gt;

&lt;p&gt;For procurement requirements, request the relevant evidence directly from each vendor. Do not infer equivalent security assurances from similar feature names or a checkbox in a comparison article.&lt;/p&gt;

&lt;p&gt;When Lumbox is a useful candidate&lt;/p&gt;

&lt;p&gt;Lumbox fits an evaluation centered on inbound verification mail, especially when the application wants a parsed code from a bounded API call. It also provides send and reply, custom domains and a credential vault. Those features have distinct configuration and authorization requirements; they are not a guarantee that a complete portal login works without integration.&lt;/p&gt;

&lt;p&gt;The $9 Starter plan gives a small team a lower subscription entry point than the AgentMail Developer plan observed here. That is a price comparison, not a claim that Lumbox is the cheapest agent email service on the market.&lt;/p&gt;

&lt;p&gt;Test with a forwarded message from the portal you care about. Check the received sender, extracted code and classification. The parser is rules based, so template coverage matters more than a generic statement that a product “supports OTP.”&lt;/p&gt;

&lt;p&gt;Make the decision with a reproducible pilot&lt;/p&gt;

&lt;p&gt;Use the same authorized portal account and message templates for both candidates. Record whether mail arrives, whether your extraction path finds the correct value, and whether the browser reaches the intended authenticated state. Include a timeout and a repeated code request.&lt;/p&gt;

&lt;p&gt;Track the integration code you maintain, active inbox count, sent and received volume, and the human steps required to onboard a customer. Keep results separate from hypotheses. A successful demonstration with one template does not establish universal portal coverage.&lt;/p&gt;

&lt;p&gt;If your immediate blocker is email-code retrieval, start with Lumbox’s &lt;a href="https://lumbox.co/docs/api-reference/" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; and one test inbox. If AgentMail already meets the requirement with an integration you trust, keeping it can be the reasonable decision.&lt;/p&gt;

&lt;p&gt;Kumar&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>comparison</category>
    </item>
    <item>
      <title>Build a support inbox agent that drafts every reply for approval</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 18:11:32 +0000</pubDate>
      <link>https://dev.to/kumard3/build-a-support-inbox-agent-that-drafts-every-reply-for-approval-2e3j</link>
      <guid>https://dev.to/kumard3/build-a-support-inbox-agent-that-drafts-every-reply-for-approval-2e3j</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/support-inbox-agent-drafts-replies-for-approval/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A lot of support email asks the same few questions: how do I change my billing email, where is my invoice, how do I cancel. The answers are already in your help docs. This post sets up a Lumbox inbox that reads each new support email, looks up the answer in those docs and in past threads, and writes a reply that waits in a queue until a person approves it.&lt;/p&gt;

&lt;p&gt;Request and response shapes for every endpoint below are in the &lt;a href="https://lumbox.co/docs/auto-reply-agent/" rel="noopener noreferrer"&gt;auto-reply agent recipe&lt;/a&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Point support@ at a Lumbox inbox&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Custom domains need the Starter plan ($9 a month). Add the domain with &lt;code&gt;POST /v1/domains&lt;/code&gt;. The response lists the DNS records to create (ownership TXT, MX, SPF, DKIM and DMARC), and &lt;code&gt;POST /v1/domains/:id/verify&lt;/code&gt; checks them. The MX records route the whole domain’s mail to Lumbox, so if your root domain already receives mail somewhere else, use a subdomain such as &lt;code&gt;help.yourco.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once the domain is verified, create the inbox:&lt;/p&gt;

&lt;p&gt;curl -X POST &lt;a href="https://api.lumbox.co/v1/inboxes" rel="noopener noreferrer"&gt;https://api.lumbox.co/v1/inboxes&lt;/a&gt; \&lt;br&gt;
  -H "X-API-Key: $LUMBOX_API_KEY" \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{"name": "Support", "domain": "help.yourco.com", "local_part": "support"}'&lt;/p&gt;

&lt;p&gt;Replies go out from &lt;code&gt;support@help.yourco.com&lt;/code&gt;, in the customer’s original thread.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste your help text as the knowledge base&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Send each help article to &lt;code&gt;POST /v1/knowledge&lt;/code&gt; with a &lt;code&gt;title&lt;/code&gt; and plain-text &lt;code&gt;content&lt;/code&gt;. Lumbox splits it into chunks and embeds them. One document can be up to 2 MB, and a Starter org can store 10 MB in total.&lt;/p&gt;

&lt;p&gt;When an email arrives, the agent has two lookup tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search_knowledge&lt;/code&gt; returns up to five chunks with a similarity score of 0.3 or higher, each with its document title and up to 1,500 characters of text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_past_conversations&lt;/code&gt; searches earlier emails in the same inbox, outside the current thread, and returns up to five matches with sender, subject, date and a 300-character snippet. The agent can limit it to emails from the current sender.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It can also read your contact record for the sender with &lt;code&gt;get_contact&lt;/code&gt; and tag the email with &lt;code&gt;set_labels&lt;/code&gt;. Its system prompt tells it not to invent order numbers, prices, dates or policies, and to hand the thread to a person when the tools do not give it an answer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on draft-first mode
curl -X POST &lt;a href="https://api.lumbox.co/v1/inboxes/$INBOX_ID/auto-reply" rel="noopener noreferrer"&gt;https://api.lumbox.co/v1/inboxes/$INBOX_ID/auto-reply&lt;/a&gt; \
-H "X-API-Key: $LUMBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"mode": "draft",
"instructions": "Answer for YourCo support. Under 120 words. Escalate refunds."
}'&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;New inboxes default to &lt;code&gt;auto&lt;/code&gt;, which sends replies without review, so pass &lt;code&gt;"mode": "draft"&lt;/code&gt; yourself. Each reply now becomes a pending draft, and Lumbox fires a &lt;code&gt;draft.created&lt;/code&gt; webhook with the draft ID, recipient, subject and the first 500 characters of the text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /v1/drafts/:id/approve&lt;/code&gt; sends the draft as written. If you add a &lt;code&gt;text&lt;/code&gt; field to that call, your edited version goes out instead, and &lt;code&gt;POST /v1/drafts/:id/reject&lt;/code&gt; discards it without sending anything. Each approval fires &lt;code&gt;draft.approved&lt;/code&gt; with the sent email’s ID and an &lt;code&gt;edited&lt;/code&gt; flag, which tells you how often the agent’s wording needed a change. The dashboard shows the same queue under Inboxes &amp;gt; Drafts, with an edit box on each draft.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Escalate to a human, in Slack&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Refunds beyond policy, account deletion and legal questions should reach a person. The agent has an &lt;code&gt;escalate_to_human&lt;/code&gt; tool for these. When it calls it, no reply or draft is written, the thread is marked &lt;code&gt;NEEDS_HUMAN&lt;/code&gt;, and Lumbox fires &lt;code&gt;thread.escalated&lt;/code&gt; with the sender, the subject and the agent’s reason.&lt;/p&gt;

&lt;p&gt;Subscribe one webhook to all three events:&lt;/p&gt;

&lt;p&gt;curl -X POST &lt;a href="https://api.lumbox.co/v1/webhooks" rel="noopener noreferrer"&gt;https://api.lumbox.co/v1/webhooks&lt;/a&gt; \&lt;br&gt;
  -H "X-API-Key: $LUMBOX_API_KEY" \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{"url": "&lt;a href="https://your-server.com/lumbox" rel="noopener noreferrer"&gt;https://your-server.com/lumbox&lt;/a&gt;", "events": ["draft.created", "draft.approved", "thread.escalated"]}'&lt;/p&gt;

&lt;p&gt;The response includes a signing secret, shown once. Lumbox posts its own JSON, so Slack needs a small relay. It checks the &lt;code&gt;X-Lumbox-Signature&lt;/code&gt; header (a hex HMAC-SHA256 of the raw body) and posts to a Slack incoming webhook, which takes a JSON body with a &lt;code&gt;text&lt;/code&gt; field (&lt;a href="https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks" rel="noopener noreferrer"&gt;Slack docs&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;import { createServer } from "node:http";&lt;br&gt;
import { createHmac, timingSafeEqual } from "node:crypto";&lt;/p&gt;

&lt;p&gt;createServer(async (req, res) =&amp;gt; {&lt;br&gt;
  let raw = "";&lt;br&gt;
  for await (const chunk of req) raw += chunk;&lt;br&gt;
  const expected = createHmac("sha256", process.env.LUMBOX_WEBHOOK_SECRET).update(raw).digest("hex");&lt;br&gt;
  const got = String(req.headers["x-lumbox-signature"] ?? "");&lt;br&gt;
  if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {&lt;br&gt;
    return res.writeHead(401).end();&lt;br&gt;
  }&lt;br&gt;
  const { event, data } = JSON.parse(raw);&lt;br&gt;
  if (event === "thread.escalated") {&lt;br&gt;
    await fetch(process.env.SLACK_WEBHOOK_URL, {&lt;br&gt;
      method: "POST",&lt;br&gt;
      headers: { "Content-Type": "application/json" },&lt;br&gt;
      body: JSON.stringify({ text: &lt;code&gt;Needs a human: "${data.subject}" from ${data.from}. ${data.reason}&lt;/code&gt; }),&lt;br&gt;
    });&lt;br&gt;
  }&lt;br&gt;
  res.writeHead(200).end();&lt;br&gt;
}).listen(3000);&lt;/p&gt;

&lt;p&gt;The same relay can post &lt;code&gt;draft.created&lt;/code&gt; to a channel, so whoever is on support sees each draft as it arrives.&lt;/p&gt;

&lt;p&gt;Some mail never reaches the agent. Lumbox skips no-reply and mailer-daemon senders, mailing lists, bulk and auto-submitted mail, and spam. It stops answering a thread once the inbox has sent five messages in it. An email that scores high on the prompt-injection screen becomes a draft even when the inbox is in &lt;code&gt;auto&lt;/code&gt; mode.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The bill&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On Starter you do not need a model key, because the agent runs on Lumbox AI. Every model call and embedding call is metered by its token counts, priced, and deducted from the plan’s monthly AI credit: $2 on Starter, $10 on Pro and $40 on Scale. The credit resets on the 1st of each month (UTC). If it runs out, new emails get no agent run until the reset, an upgrade, or until you save your own key with &lt;code&gt;PUT /v1/ai-config&lt;/code&gt;, after which model calls bill to your provider account. &lt;code&gt;GET /v1/usage/ai&lt;/code&gt; shows the month’s spend, with agent replies listed under &lt;code&gt;auto_reply&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the test run for this post, two drafted replies used 3,111 input and 144 output tokens and cost $0.000421 of AI credit, about $0.0002 a reply, on top of the $9 plan. At that rate Starter’s $2 credit covers roughly 9,500 replies a month. Approved replies also count toward the plan’s monthly sends, which is 2,000 on Starter.&lt;/p&gt;

&lt;p&gt;Free works for a trial on a &lt;code&gt;trylumbox.com&lt;/code&gt; address, with three limits: no AI credit, no custom domain, and 100 sends a month. On Free you save your own model key, and the knowledge base needs an OpenAI key or a connected Cloudflare account for embeddings.&lt;/p&gt;

&lt;p&gt;For comparison, Intercom’s Fin, used with a helpdesk you already have, costs $0.99 per outcome with a 50-outcome monthly minimum (&lt;a href="https://fin.ai/pricing" rel="noopener noreferrer"&gt;fin.ai/pricing&lt;/a&gt;, checked 2026-10-04). Fin’s help center describes that floor as a $49 a month base plan that includes 50 resolutions (&lt;a href="https://fin.ai/help/en/articles/13975800-fin-pricing-outcomes" rel="noopener noreferrer"&gt;Fin pricing: Outcomes&lt;/a&gt;). Fin also covers chat, WhatsApp and SMS and answers customers directly. The setup in this post covers email only, and a person approves each reply.&lt;/p&gt;

&lt;p&gt;The full recipe, including custom HTTP tools for lookups such as order status, is at &lt;a href="https://lumbox.co/docs/auto-reply-agent/" rel="noopener noreferrer"&gt;lumbox.co/docs/auto-reply-agent&lt;/a&gt;. &lt;a href="https://app.lumbox.co/signup" rel="noopener noreferrer"&gt;Create an account&lt;/a&gt; to try it.&lt;/p&gt;

</description>
      <category>aiagents</category>
    </item>
    <item>
      <title>Email for browser agents using Skyvern, Browserbase or Steel</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 18:11:31 +0000</pubDate>
      <link>https://dev.to/kumard3/email-for-browser-agents-using-skyvern-browserbase-or-steel-5a9n</link>
      <guid>https://dev.to/kumard3/email-for-browser-agents-using-skyvern-browserbase-or-steel-5a9n</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/email-browser-agents-skyvern-browserbase-steel/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Add email verification to a browser agent by connecting its login workflow to an inbox API, retrieving the matching code, and passing that value back to the active browser challenge. Lumbox can supply the email side of this design while Skyvern, Browserbase or Steel supplies the browser workflow, with adapter work depending on the platform you use.&lt;/p&gt;

&lt;p&gt;The useful unit of evaluation is a customer portal login. A browser can navigate a form correctly while the code travels somewhere the runtime cannot read. Replacing the browser provider does not necessarily change that email route. Start by identifying the destination mailbox and the owner who can authorize access or forwarding.&lt;/p&gt;

&lt;p&gt;Define the handoff&lt;/p&gt;

&lt;p&gt;Keep browser state and email state connected in your application. Save the customer account reference, portal origin, browser session, inbox ID and attempt start time before requesting the code. These are application records, not fields that Lumbox automatically adds to a portal email.&lt;/p&gt;

&lt;p&gt;The email adapter’s job is to return a candidate verification value with enough context to inspect it. The browser adapter’s job is to place that value into the intended challenge and check the result. If either adapter times out, the parent workflow decides what happens next.&lt;/p&gt;

&lt;p&gt;Part of the workflowResponsibilityCustomer onboardingEstablish authorized access and email routingBrowser controllerNavigate, request a code and identify the challengeEmail adapterRetrieve a matching message or parsed codeApplication stateAssociate the result with the active attemptBrowser verificationConfirm the portal accepted the login&lt;/p&gt;

&lt;p&gt;This boundary also makes vendor evaluation easier. You can exercise the email adapter with a controlled message before running a whole browser task. You can test the browser with a manually supplied code before diagnosing an inbound-email delay.&lt;/p&gt;

&lt;p&gt;Skyvern: inspect its existing authentication path first&lt;/p&gt;

&lt;p&gt;Skyvern documents authenticator-based TOTP and email or text-message code handling, including ways to push codes into a waiting workflow. Its &lt;a href="https://www.skyvern.com/docs/cloud/managing-credentials/totp-setup" rel="noopener noreferrer"&gt;2FA setup documentation&lt;/a&gt; is the right starting point for deciding where an external code source belongs.&lt;/p&gt;

&lt;p&gt;Do not add another integration merely because the workflow involves email. If Skyvern’s existing authentication route already meets the customer’s needs, use that. If you need a separate inbox service, retrieve the code in your application and connect it to the documented Skyvern input for the active run. Verify the current input contract before implementing the adapter.&lt;/p&gt;

&lt;p&gt;This article proposes an architecture. It does not establish a native Lumbox connector in Skyvern, and it does not claim an end-to-end integration has been tested. The distinction matters when estimating implementation work for a pilot.&lt;/p&gt;

&lt;p&gt;Browserbase: keep email retrieval in your orchestration&lt;/p&gt;

&lt;p&gt;Browserbase provides infrastructure for agents that browse and interact with the web. Its &lt;a href="https://docs.browserbase.com/welcome/introduction" rel="noopener noreferrer"&gt;platform introduction&lt;/a&gt; describes cloud browsers and other agent infrastructure. Your application can coordinate a browser task with an HTTP request to Lumbox.&lt;/p&gt;

&lt;p&gt;When the browser reaches a code challenge, preserve the session and call the email adapter from the runtime controlling the task. Once a suitable result arrives, resume the intended interaction. Avoid letting an unrelated browser task consume a result simply because it is the latest one in a shared mailbox.&lt;/p&gt;

&lt;p&gt;An adapter does not need to make the model responsible for polling. Deterministic application code can wait, classify the outcome and expose only the result needed for the next browser action. That leaves the browser agent free to handle page interaction without repeatedly asking whether mail has arrived.&lt;/p&gt;

&lt;p&gt;Steel: distinguish existing Lumbox tools from a custom adapter&lt;/p&gt;

&lt;p&gt;Steel documents a browser API, session management and credential capabilities in its &lt;a href="https://docs.steel.dev/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;. Lumbox’s repository also contains Steel-backed browser tools. Those tools require the relevant browser configuration; installing an email MCP server is not proof that a browser session is ready.&lt;/p&gt;

&lt;p&gt;If you already control Steel directly, the same external email-adapter pattern applies. If you use Lumbox’s browser tools, inspect the &lt;a href="https://lumbox.co/docs/browser/" rel="noopener noreferrer"&gt;Lumbox browser documentation&lt;/a&gt; and test the tool path you intend to call. Do not assume a compound signup tool handles every portal’s challenge sequence.&lt;/p&gt;

&lt;p&gt;Choose one controller for a given session. Two independent agents typing into the same challenge can create a confusing failure even when the email response is correct. Session ownership belongs in the workflow design.&lt;/p&gt;

&lt;p&gt;The Lumbox retrieval contract&lt;/p&gt;

&lt;p&gt;For an existing inbox, call &lt;code&gt;GET /v1/inboxes/:id/otp&lt;/code&gt; with an explicit &lt;code&gt;since&lt;/code&gt; timestamp and, where useful, a received-sender filter. It waits 30 seconds by default and accepts up to 120 seconds. A successful response includes &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;all_codes&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;expires&lt;/code&gt; and &lt;code&gt;email_id&lt;/code&gt;; HTTP 408 indicates no suitable code before timeout. See the &lt;a href="https://lumbox.co/docs/api-reference/" rel="noopener noreferrer"&gt;API reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The route selects mail in the verification category, and extraction depends on parser rules. Forwarded templates therefore need their own check. The sender seen by Lumbox may be the forwarder. A substring sender filter cannot authenticate the portal or uniquely identify the browser attempt.&lt;/p&gt;

&lt;p&gt;For a magic link, read the appropriate structured link fields from an email response instead of expecting the numeric-code shortcut to solve the task. For a phone-only challenge, an email inbox alone does not provide a phone number or receive SMS directly.&lt;/p&gt;

&lt;p&gt;Run a small integration trial&lt;/p&gt;

&lt;p&gt;Use an owned test account and write down the expected outcome before recording a demo. First, establish that the message arrives and is parsed. Then test the handoff into the browser. Finally, confirm the authenticated page represents the account you intended to access.&lt;/p&gt;

&lt;p&gt;Include a delayed message and a second overlapping attempt. Check that the application neither switches inboxes accidentally nor submits a stale result after the browser has moved on. Keep a human handoff available when the route or challenge changes.&lt;/p&gt;

&lt;p&gt;Record the result by stage rather than reporting only “login failed.” Useful stages are delivery, extraction, retrieval, browser entry and portal acceptance. This gives your team a concrete basis for comparing browser providers and inbox services without attributing every failure to the model.&lt;/p&gt;

&lt;p&gt;For a first Lumbox trial, the free plan includes three inboxes. That supports a small routing experiment, subject to its email limits. Use the &lt;a href="https://lumbox.co/docs/pricing/" rel="noopener noreferrer"&gt;pricing documentation&lt;/a&gt; for current allowances, then size the pilot around the inboxes you need to keep active.&lt;/p&gt;

&lt;p&gt;Implementation references: &lt;code&gt;apps/api/src/routes/inboxes.ts&lt;/code&gt;, &lt;code&gt;apps/mcp-server/src/tools.ts&lt;/code&gt;, &lt;code&gt;apps/docs/content/docs/browser.mdx&lt;/code&gt;, and &lt;code&gt;packages/plans/src/index.ts&lt;/code&gt;. Platform documentation and source checked October 3, 2026. No live integration benchmark is claimed.&lt;/p&gt;

&lt;p&gt;Kumar&lt;/p&gt;

</description>
      <category>aiagents</category>
    </item>
    <item>
      <title>How AI agents receive email OTP codes</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Tue, 06 Oct 2026 18:00:44 +0000</pubDate>
      <link>https://dev.to/kumard3/how-ai-agents-receive-email-otp-codes-1i5</link>
      <guid>https://dev.to/kumard3/how-ai-agents-receive-email-otp-codes-1i5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/how-ai-agents-receive-email-otp-codes/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An AI agent can receive an email OTP by routing the verification message into an inbox it can access, then reading the extracted code through an API. Lumbox provides that inbox and a &lt;code&gt;GET /v1/inboxes/:id/otp&lt;/code&gt; endpoint that waits for a matching message for up to 120 seconds.&lt;/p&gt;

&lt;p&gt;For a startup building agents that log into customer portals, the first question is where the portal sends its email. An agent-created account can use an agent-owned address. An existing customer account may still send codes to the customer’s mailbox. In that case, the customer needs to configure a suitable forwarding rule, or your application needs an authorized connection to that mailbox. Creating another inbox does not change the portal’s delivery address.&lt;/p&gt;

&lt;p&gt;Establish the route before debugging the agent&lt;/p&gt;

&lt;p&gt;Begin with one customer-authorized workflow and one known message template. Create a Lumbox inbox, save its returned ID and address, and arrange delivery to that address. The &lt;a href="https://lumbox.co/docs/inboxes/" rel="noopener noreferrer"&gt;inbox documentation&lt;/a&gt; describes inbox management. Use the returned address instead of constructing one from a presumed default domain.&lt;/p&gt;

&lt;p&gt;Then trigger a verification email in a test account you control. Inspect the received message before involving the browser agent. Check which address appears as the sender and whether forwarding changes the subject or body. A forwarding service can become the received sender, so filtering for the original portal’s domain may miss the forwarded message.&lt;/p&gt;

&lt;p&gt;This separates a routing problem from an extraction problem. If the inbox contains no message, changing the model prompt will not repair delivery. If a message is present but has no extracted code, inspect its actual content and classification. If a code comes back but the portal rejects it, investigate the login attempt and expiry separately.&lt;/p&gt;

&lt;p&gt;Make one bounded retrieval request&lt;/p&gt;

&lt;p&gt;The following request assumes that the inbox already exists and mail is routed to it. Set &lt;code&gt;LUMBOX_API_KEY&lt;/code&gt;, &lt;code&gt;LUMBOX_INBOX_ID&lt;/code&gt;, &lt;code&gt;ATTEMPT_STARTED_AT&lt;/code&gt;, and &lt;code&gt;EXPECTED_RECEIVED_SENDER&lt;/code&gt; in your runtime. Capture the attempt timestamp immediately before requesting the portal’s code. The sender value should match what arrives in the destination inbox.&lt;/p&gt;

&lt;p&gt;curl --get --max-time 130 \&lt;br&gt;
  "&lt;a href="https://api.lumbox.co/v1/inboxes/$%7BLUMBOX_INBOX_ID%7D/otp" rel="noopener noreferrer"&gt;https://api.lumbox.co/v1/inboxes/${LUMBOX_INBOX_ID}/otp&lt;/a&gt;" \&lt;br&gt;
  -H "X-API-Key: ${LUMBOX_API_KEY}" \&lt;br&gt;
  --data-urlencode "timeout=120" \&lt;br&gt;
  --data-urlencode "since=${ATTEMPT_STARTED_AT}" \&lt;br&gt;
  --data-urlencode "from=${EXPECTED_RECEIVED_SENDER}"&lt;/p&gt;

&lt;p&gt;The server’s default timeout is 30 seconds. It clamps the requested timeout between 1 and 120 seconds. Configure the HTTP client and any proxy to permit the intended wait, with some transport margin. A client that gives up earlier can hide the server’s eventual result.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://lumbox.co/docs/api-reference/" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; is the public entry point. The inspected implementation returns &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;all_codes&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;expires&lt;/code&gt;, and &lt;code&gt;email_id&lt;/code&gt;. Use &lt;code&gt;code&lt;/code&gt;, not an assumed &lt;code&gt;otp&lt;/code&gt; field. These names matter when an agent hands the response to deterministic application code.&lt;/p&gt;

&lt;p&gt;Know what matching actually means&lt;/p&gt;

&lt;p&gt;The endpoint looks for recent messages categorized as &lt;code&gt;verification&lt;/code&gt; and returns the first extracted code from the newest matching message when codes are available. Without &lt;code&gt;since&lt;/code&gt;, it uses a five-minute lookback. The optional &lt;code&gt;from&lt;/code&gt; filter uses a substring match against the received sender address.&lt;/p&gt;

&lt;p&gt;A sender filter is a retrieval filter. It is not proof that a message belongs to a particular browser run. A timestamp narrows the window, but two overlapping attempts against the same account can still fall inside it. Keep the relationship between customer, portal account, browser session, inbox and attempt in your application.&lt;/p&gt;

&lt;p&gt;ObservationWhat it establishesWhat to check nextNo message in the destination inboxThe retrieval path has no mail to inspectPortal delivery and forwardingMessage exists, no suitable parsed codeReceipt alone is insufficientTemplate, category and extracted fieldsHTTP 200 with &lt;code&gt;code&lt;/code&gt;A matching parsed code was returnedCorrect attempt and portal acceptanceHTTP 408No suitable code was found before the wait endedDelivery, filters and parser output&lt;/p&gt;

&lt;p&gt;Lumbox’s parser uses patterns and category rules. This is useful for predictable automation, but it is not a promise that every email template will match. Test the exact messages your buyers receive, including the forwarded version. The &lt;a href="https://lumbox.co/docs/emails/" rel="noopener noreferrer"&gt;email documentation&lt;/a&gt; covers the structured message representation.&lt;/p&gt;

&lt;p&gt;Treat a timeout as an application decision&lt;/p&gt;

&lt;p&gt;A timeout returns HTTP 408 with &lt;code&gt;error: "No OTP found"&lt;/code&gt; and the timeout value. It does not tell you whether the portal failed to send, forwarding was delayed, the filter excluded the message, or parsing produced no suitable result.&lt;/p&gt;

&lt;p&gt;Record the stage that failed without putting the code or full email body in ordinary logs. A useful diagnostic record includes the attempt ID your application owns, the inbox ID, request start time, response status and whether the browser remained on the challenge screen. Store sensitive message content only where your support process needs it.&lt;/p&gt;

&lt;p&gt;Decide in advance whether to retry retrieval, request another code, or hand control to the customer. Requesting another code may change what the portal accepts. That behavior belongs to the portal, so a generic retry loop should not assume older codes remain valid.&lt;/p&gt;

&lt;p&gt;Fit retrieval into the browser workflow&lt;/p&gt;

&lt;p&gt;Once you have a candidate code, check that the browser still represents the intended account and challenge. Supply the code through your browser controller, then inspect a defined success condition such as the authenticated account page. A successful inbox read and a successful login are different events.&lt;/p&gt;

&lt;p&gt;Your application should also remember completed attempts. The OTP endpoint is not a consume-once queue, and another request can return the same recent message. Preventing duplicate use or simultaneous submissions is your orchestration responsibility.&lt;/p&gt;

&lt;p&gt;This pattern is a good fit when the missing part of a portal agent is receiving email and extracting a verification value. If your application already has authorized mailbox access and parsing that works for its templates, retaining that route may be simpler. Lumbox is useful when you want an agent-owned inbox and a bounded retrieval API without building the client polling loop yourself.&lt;/p&gt;

&lt;p&gt;The server still polls its database internally once per second. “One call” describes the client’s retrieval step after provisioning and routing, not an entire login or an absence of polling anywhere in the system.&lt;/p&gt;

&lt;p&gt;Implementation references, relative to the Lumbox repository: &lt;code&gt;apps/api/src/routes/inboxes.ts&lt;/code&gt; (OTP and wait handlers), &lt;code&gt;apps/api/src/utils/parser.ts&lt;/code&gt; (extraction and classification), and &lt;code&gt;apps/docs/content/docs/api-reference.mdx&lt;/code&gt; (public reference source). Checked October 3, 2026.&lt;/p&gt;

&lt;p&gt;Kumar&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>otp</category>
    </item>
    <item>
      <title>Sequential IDs Are a Security Decision</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Thu, 10 Sep 2026 06:00:07 +0000</pubDate>
      <link>https://dev.to/kumard3/sequential-ids-are-a-security-decision-2gb9</link>
      <guid>https://dev.to/kumard3/sequential-ids-are-a-security-decision-2gb9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/sequential-ids-are-a-security-decision/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A convenient helper, of a kind that exists in most codebases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findByRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM runs WHERE id = ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM runs WHERE public_id = ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take an id or a public handle, return the row. Scripts pass the numeric id, the web passes the handle. It reads as a kindness.&lt;/p&gt;

&lt;p&gt;Now route an HTTP path segment through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/runs/:id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findByRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;leadsOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;shareUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;shareFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every URL the application generates contains a random handle with 72 bits of entropy. Nobody guesses one. But the route also accepts &lt;code&gt;1&lt;/code&gt;, and &lt;code&gt;2&lt;/code&gt;, and &lt;code&gt;3&lt;/code&gt;, and now the entire table is walkable with a for loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gate did not catch it
&lt;/h2&gt;

&lt;p&gt;The route was deliberately unauthenticated, and for a defensible reason: the page for a run polls its own status, and running that poll through the authenticated, rate-limited path would have made a page consume its own quota. Public reads were an intentional design decision.&lt;/p&gt;

&lt;p&gt;That decision is completely fine, and it is only fine while the identifier is unguessable. The auth rule and the identifier scheme were two separate choices made by two different people at two different times, and the security property lived in the space between them where nothing tested it.&lt;/p&gt;

&lt;p&gt;The matcher did not help either. It was anchored:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;const COSTLY = /^\/api\/(runs|run|brief)\/?$/;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That matches &lt;code&gt;/api/runs&lt;/code&gt; and nothing below it. The collection was rate limited. Every individual resource under it was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a second function, not a check
&lt;/h2&gt;

&lt;p&gt;The instinct is to add a validation branch to the handler. Better to make the unsafe capability unreachable from the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** The only lookup a request off the network may use. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findByPublicId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM runs WHERE public_id = ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Internal callers keep the flexible helper. HTTP handlers get the narrow one. The rule is now expressed in the type of thing a handler can call, rather than in a check somebody has to remember to write in each new route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the test against the vulnerability
&lt;/h2&gt;

&lt;p&gt;A test asserting the new function works is nearly worthless, because it would pass before the bug existed and after it was reintroduced somewhere else. Assert the distinction instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;findByRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the numeric path still exists internally&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;findByPublicId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;and is unreachable publicly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That test documents why the two functions exist. Delete the second one and it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things worth taking away
&lt;/h2&gt;

&lt;p&gt;First, a helper that accepts several kinds of identifier is a fine internal convenience and a poor public boundary. The flexibility is the vulnerability.&lt;/p&gt;

&lt;p&gt;Second, when you deliberately leave something unauthenticated, write down what is carrying the security instead. If the answer is "the identifier is unguessable", then any code path that accepts a guessable identifier for the same resource is a bug, and it is worth a test that says so.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>security</category>
      <category>apidesign</category>
    </item>
    <item>
      <title>Plan Then Execute: One LLM Call Instead of Thirty</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Wed, 09 Sep 2026 18:00:12 +0000</pubDate>
      <link>https://dev.to/kumard3/plan-then-execute-one-llm-call-instead-of-thirty-gc9</link>
      <guid>https://dev.to/kumard3/plan-then-execute-one-llm-call-instead-of-thirty-gc9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/plan-then-execute-browser-agents/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have built a browser agent, you have probably shipped this loop: send the current page to the model, ask what to do next, execute that one action, repeat. It is the default across browser-use, Stagehand's agent mode, and most framework browser tools. The model is the brain, the browser is the body, and every step is a round trip.&lt;/p&gt;

&lt;p&gt;There is a different shape. Make &lt;strong&gt;one&lt;/strong&gt; model call that writes the whole plan down as JSON, then run that plan with a deterministic executor that never calls a model at all. This post is what that looks like in practice, including the exact schema we run in production, and honest arithmetic on what it saves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the loop is expensive twice over
&lt;/h2&gt;

&lt;p&gt;The obvious cost is the round trips. The less obvious one is what you send on each of them.&lt;/p&gt;

&lt;p&gt;An agent loop has to include the current page state on every call, because that is the only thing telling the model where it is. A serialised DOM or accessibility tree for a real signup page is commonly ten to fifty thousand tokens. Twenty steps at twenty five thousand tokens each is half a million input tokens for one task.&lt;/p&gt;

&lt;p&gt;The structural cost is worse. At every step the model has to re-derive the plan it already held implicitly one step earlier. Ask it to sign up for a product and it sketches the same sequence in its head each time: go to the signup page, fill the email, submit, wait for the code, paste the code, verify. Then it picks one action and throws the rest away. Next step, it sketches it again. Most of the time it lands in the same place. Sometimes it picks the login link instead of the signup link, and every subsequent step compounds that.&lt;/p&gt;

&lt;p&gt;Linear goals do not need this. "Sign up with this address" has one obvious sequence. The model is very good at writing that sequence down once. It is mediocre at re-deriving it twenty times without drifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;goal --&amp;gt; planner (1 LLM call) --&amp;gt; JSON plan --&amp;gt; executor (0 LLM calls) --&amp;gt; result&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The planner gets one shot and is told so in its system prompt. It has no follow-up call to correct itself, which is deliberate: it makes the model plan defensively rather than optimistically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ten step types, exactly
&lt;/h2&gt;

&lt;p&gt;This is the real schema, not a simplification. A plan is &lt;code&gt;{ "reasoning": string, "steps": Step[] }&lt;/code&gt; and a step is one of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"navigate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"describe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"selector"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"describe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"selector"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_seconds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;number&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timeout_seconds"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;number&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for_email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox_id"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"from"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subject"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timeout_seconds"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;number&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"use_otp_from_inbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox_id"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timeout_seconds"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;number&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"open_link_from_inbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox_id"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"from"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timeout_seconds"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;number&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"extract_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"describe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"selector"&lt;/span&gt;&lt;span class="err"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three constraints in the planner prompt do most of the quality work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every plan ends with &lt;code&gt;done&lt;/code&gt;.&lt;/strong&gt; The executor breaks out of its loop on &lt;code&gt;done&lt;/code&gt; and takes the summary as the task result. A plan without one runs to the end of the array and returns a generic summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer &lt;code&gt;text&lt;/code&gt; over &lt;code&gt;selector&lt;/code&gt; on a click.&lt;/strong&gt; Humans see text, and text survives a CSS refactor. Selectors are the fallback, not the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep plans under fifteen steps.&lt;/strong&gt; Long flows fail. If the goal is bigger than that, the planner is told to plan the first sub-goal only and emit &lt;code&gt;done&lt;/code&gt; with a summary of what is left, so the caller can decide whether to continue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Values can carry &lt;code&gt;{{inbox_address}}&lt;/code&gt;, which the executor substitutes before each step runs. That is what lets a plan be written before the inbox it will use exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three steps that cross into email
&lt;/h2&gt;

&lt;p&gt;Six of the ten types are ordinary browser actions. Three are not, and they are the reason this architecture is worth describing at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wait_for_email&lt;/code&gt; blocks until a message matching the filter lands in the bound inbox&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;use_otp_from_inbox&lt;/code&gt; waits for the mail, extracts the code, and fills it into a selector&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;open_link_from_inbox&lt;/code&gt; waits for the mail, finds the verification or magic link, and navigates to it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In an agent loop, "check the email" is not an action the browser can take. You break out of the loop, poll a mail API in your own code, feed the result back in as text, and hope the model picks up where it left off. That handoff is where a lot of signup automation actually breaks.&lt;/p&gt;

&lt;p&gt;Here it is one step in the plan, because the inbox and the browser session are in the same runtime. A full email-verified signup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Standard email signup with a code sent to the inbox."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"navigate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/signup"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"describe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email field"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{inbox_address}}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"describe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"submit the signup form"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sign up"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for_email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeout_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"use_otp_from_inbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[name='code']"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"describe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"confirm the code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Verify"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeout_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Account created and email verified."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No glue code between the browser and the mailbox, because there is no boundary to glue across.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;

&lt;p&gt;We have not run a published benchmark, so here is the calculation instead of a number to take on faith. Substitute your own figures; the shape holds.&lt;/p&gt;

&lt;p&gt;Take a twenty step task, twenty five thousand tokens of page state per step, priced at Claude Sonnet rates of $2 per million input tokens and $10 per million output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent loop.&lt;/strong&gt; Twenty calls carrying 25k tokens each is 500,000 input tokens, or &lt;strong&gt;$1.00&lt;/strong&gt;, before a single output token. Add roughly 500 output tokens per step and you are at about $1.10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan then execute.&lt;/strong&gt; One call. The system prompt is roughly 600 tokens and is marked for caching, so it costs about a tenth of that on every call after the first. The goal plus inbox context is perhaps 1,000 tokens. Output is capped at 2,048. Call it 1,700 input and 800 output: &lt;strong&gt;about $0.011&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is roughly two orders of magnitude, and the dominant term is not the number of calls. It is that the loop pays for the page state twenty times and the planner never pays for it at all. If your pages serialise smaller, the gap narrows proportionally. If you run more steps, it widens.&lt;/p&gt;

&lt;p&gt;One thing that is easy to get wrong here: the planner call is a good candidate for prompt caching precisely because the system prompt is frozen and comes first, while the volatile part (the goal, the inbox context) goes last. Reverse that order and the cache never hits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this architecture does not do
&lt;/h2&gt;

&lt;p&gt;Being straight about the limits, because they are the reason to keep a loop around for some jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no replanning.&lt;/strong&gt; The executor runs each step, and on the first failure it records the error, stops, and returns everything up to that point. It does not retry, ask a model for a fix, or improvise. When a page has changed since the plan was written, the plan is wrong and the task fails. That is a deliberate trade: predictable failure with a full step-by-step event log beats an agent that quietly does the wrong thing for another fifteen steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no plan cache.&lt;/strong&gt; Every call plans from scratch today. Caching by goal and domain is the obvious next win and it is not built.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exploratory goals do not fit.&lt;/strong&gt; "Find a product under fifty dollars" or "research this site and report back" have no linear sequence to write down. The whole premise is that the plan is knowable in advance. When it is not, you want the loop, and you should pay for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fifteen step cap is real.&lt;/strong&gt; Longer plans get noisier and the model starts over-explaining. Splitting into sub-goals with an explicit handoff summary works better than raising the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use which
&lt;/h2&gt;

&lt;p&gt;Goal shapeUse&lt;br&gt;
Linear and repeatable: signups, email verification, password resets, form fills, single page extractionPlan then execute&lt;br&gt;
Branchy and exploratory: research, comparison, navigating an unfamiliar menu treeStep level agent loop&lt;/p&gt;

&lt;p&gt;Most production browser automation is the first row. It is worth knowing that the first row does not need an agent at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general version
&lt;/h2&gt;

&lt;p&gt;This is not really about browsers. Whenever a model's strength is sketching a sequence once and its weakness is re-deriving that sequence repeatedly, plan then execute wins: multi step API workflows, file operations, migration scripts. The test is simple. Ask whether the plan is knowable before you start. If it is, write it down once and run it with an interpreter you control. If it genuinely is not, that is what the loop is for.&lt;/p&gt;

&lt;p&gt;The runtime described here is the &lt;a href="https://lumbox.co/browser-act/" rel="noopener noreferrer"&gt;browser act endpoint&lt;/a&gt;: one call, one planner hit, deterministic execution, with the inbox in the same runtime so the verification steps are primitives rather than glue.&lt;/p&gt;

</description>
      <category>browserautomation</category>
      <category>aiagents</category>
      <category>costoptimization</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Pagination Is an Agent Experience Problem</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Wed, 09 Sep 2026 18:00:11 +0000</pubDate>
      <link>https://dev.to/kumard3/pagination-is-an-agent-experience-problem-4k1i</link>
      <guid>https://dev.to/kumard3/pagination-is-an-agent-experience-problem-4k1i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/pagination-is-an-agent-experience-problem/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A list endpoint returning everything is a familiar, forgivable shortcut. A human calling it gets a slow page and scrolls. A browser handles a 794KB JSON response without complaint.&lt;/p&gt;

&lt;p&gt;An agent does not. The response goes into a context window, and a large one either truncates the conversation, evicts the instructions that came before it, or fails outright. The tool call does not return data, it returns a wall.&lt;/p&gt;

&lt;p&gt;We had this on &lt;code&gt;list_inboxes&lt;/code&gt;. An account that provisions an inbox per task accumulates them quickly, and one had 3,219. The response was 794,423 characters across 28,975 lines. It exceeded the caller's limit and returned nothing usable, on the MCP surface we describe as the reason to use the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The costs are different
&lt;/h2&gt;

&lt;p&gt;For a human API the cost of a big response is latency and bandwidth, both of which degrade gracefully. For an agent the costs are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context.&lt;/strong&gt; Finite and shared with everything else the agent needs to remember. A large response can evict its own instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Money.&lt;/strong&gt; Tokens are billed. A response nobody reads is still paid for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attention.&lt;/strong&gt; Even within limits, burying three relevant rows in three thousand measurably degrades what the model does next.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Graceful degradation does not exist here. It works, or the turn is destroyed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a paginated response owes an agent
&lt;/h2&gt;

&lt;p&gt;Human pagination assumes a UI that renders "next". An agent has to decide, from the response alone, whether to continue. So say so explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rows&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inb_a1HjMJs44WShWc3CpEeI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3226&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;total&lt;/code&gt; is the field people leave out and it is the one that changes behaviour. Without it, the agent knows there is more but not whether that means one page or sixty five. With it, it can decide to filter instead of paging, or tell the user the list is large, or stop. Return it on the first page only, since recounting on every page is wasted work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the tool description carry it
&lt;/h2&gt;

&lt;p&gt;The schema is not enough. The model reads the description, so the description has to explain the protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"List the email inboxes you have created, newest first. Paginated: the
first page reports 'total', and 'has_more' with 'cursor' means there are
more. Pass that cursor back to continue."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Do not break the SDKs
&lt;/h2&gt;

&lt;p&gt;Adding a default limit changes behaviour for everyone who was relying on getting everything. A method documented as returning all inboxes should keep doing that, which means the SDK follows the cursor internally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;listInboxes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/inboxes?limit=200&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;cursor=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;has_more&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent-facing tool exposes the pages. The library-facing method hides them. Those are different audiences and it is fine for them to differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  One implementation note
&lt;/h2&gt;

&lt;p&gt;If your ids are random, a cursor of &lt;code&gt;WHERE id &amp;lt; :cursor&lt;/code&gt; is not correct, because random ids do not sort in the order you are returning rows. Use your database's cursor primitive, which walks the ordered result, or key the cursor on the actual sort column. This bug is quiet: it works for page one and silently skips or repeats rows later.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>apidesign</category>
      <category>agents</category>
      <category>ax</category>
    </item>
    <item>
      <title>We Buy the Browser and Build the Inbox</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Wed, 09 Sep 2026 06:00:32 +0000</pubDate>
      <link>https://dev.to/kumard3/we-buy-the-browser-and-build-the-inbox-2gkg</link>
      <guid>https://dev.to/kumard3/we-buy-the-browser-and-build-the-inbox-2gkg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/build-vs-buy-agent-runtime/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An agent that can act on the web needs two things that are easy to confuse. It needs a browser that does not get blocked, and it needs an identity that can receive mail, because almost every meaningful action behind a login starts with a code or a link sent to an address.&lt;/p&gt;

&lt;p&gt;They look like two halves of one product. They are not. One of them is a commodity and one of them is not, and getting that backwards is an expensive way to spend a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The commodity half
&lt;/h2&gt;

&lt;p&gt;Stealth browser runtimes are a solved and crowded category. Steel ships a self-hostable stealth Chromium. Browserbase ships the hosted version. Browser-use added stealth sessions. Anthropic and Microsoft both expose browser-level primitives directly to a model. These products differ on price, on residential proxy quality, on geography, and on how quickly they respond when an anti-bot vendor ships an update. They do not differ on what they fundamentally are.&lt;/p&gt;

&lt;p&gt;When four or five funded teams ship the same primitive, the honest read is that it is infrastructure now. The correct move for a small team is to buy it.&lt;/p&gt;

&lt;p&gt;So we run on Steel. &lt;code&gt;browser-executor.ts&lt;/code&gt; opens a Steel session with stealth on, runs the plan against it, and deletes the session in a &lt;code&gt;finally&lt;/code&gt; block so a thrown step cannot leak a browser. That is the entire relationship. It is about a hundred lines of client code, and if the anti-bot landscape shifts, that is Steel's problem to solve and ours to benefit from.&lt;/p&gt;

&lt;p&gt;We would not win a stealth arms race against five better-resourced teams. Entering it would have cost us the thing we can actually win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half that is not a commodity
&lt;/h2&gt;

&lt;p&gt;Now the other side. An agent needs an address that can receive a verification code. The available options are worse than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A real mailbox on a consumer provider.&lt;/strong&gt; Automating a personal account is against terms, and consumer providers are aggressive about flagging automated access. This works until it very suddenly does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A disposable inbox service.&lt;/strong&gt; Built for humans testing signup flows. Public inboxes, addresses on domains that half the internet already blocks, and no API contract you would put in a production path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A raw transactional email provider.&lt;/strong&gt; Excellent at sending. Receiving means running your own MX, parsing MIME, and building everything above it yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is a mailbox an agent can reason about. That is a different object, and the gap between "an email arrived" and "the agent knows what to do" is where the work actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test we used
&lt;/h2&gt;

&lt;p&gt;One question, applied to each half: &lt;em&gt;if we do nothing here, does someone else's roadmap solve it for us within a year?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the browser, the answer was clearly yes. Five teams are already sprinting at it and their output arrives as a version bump.&lt;/p&gt;

&lt;p&gt;For the agent mailbox, the answer was no. Every adjacent vendor is optimising for a different customer. Transactional email vendors optimise for sending at volume. Disposable inbox tools optimise for a human watching a test run. Nobody in that set has a reason to build long-poll retrieval or structured extraction, because their users do not need it.&lt;/p&gt;

&lt;p&gt;Build where the answer is no. Buy where the answer is yes. It is not a sophisticated test, but applied honestly it kills a lot of appealing projects, including our own stealth browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "a mailbox an agent can reason about" means concretely
&lt;/h2&gt;

&lt;p&gt;The difference is not the mail. It is what sits between the mail arriving and the agent continuing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocking retrieval instead of polling.&lt;/strong&gt; An agent waiting for a code should not run a sleep-and-check loop, burning tokens re-reading an empty inbox and guessing at an interval. It should make one request that returns when the mail lands or times out. A polling loop is a retry policy the caller has to invent; a long poll is one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extraction as part of the contract.&lt;/strong&gt; The agent wants the six digit code, not a MIME tree. Handing a model the full HTML body of a marketing-styled verification email and asking it to find the code costs tokens and introduces a failure mode that did not need to exist. Pulling the code out is deterministic string work. Do it once, server side, and return the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same for links.&lt;/strong&gt; Magic link flows need the link extracted and followed, not summarised.&lt;/p&gt;

&lt;p&gt;That is why the browser plan has &lt;code&gt;wait_for_email&lt;/code&gt;, &lt;code&gt;use_otp_from_inbox&lt;/code&gt;, and &lt;code&gt;open_link_from_inbox&lt;/code&gt; as first class steps rather than as escape hatches into user code. Because the inbox and the browser session are in one runtime, the verification step in a signup flow is a step in the plan, not a handoff out of the plan and back into it. Most signup automation breaks at exactly that handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are honest about
&lt;/h2&gt;

&lt;p&gt;Buying the browser is a real dependency. If Steel has an outage, browser tasks fail; a provider abstraction is on the roadmap and it is not built today, so a swap would mean a code change, not a config change. That is the price of the decision and it is the right price to pay at our size. Writing an abstraction layer for a second implementation you do not have yet is how small teams accidentally build frameworks instead of products.&lt;/p&gt;

&lt;p&gt;Some things are worth owning. A stealth Chromium is not one of them when five teams already ship it. The mailbox the agent thinks with is.&lt;/p&gt;

&lt;p&gt;If you want the second half without building it, that is what &lt;a href="https://lumbox.co" rel="noopener noreferrer"&gt;Lumbox&lt;/a&gt; is: a real inbox per agent, with blocking retrieval and structured extraction, on one API key.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aiagents</category>
      <category>browserautomation</category>
      <category>buildvsbuy</category>
    </item>
    <item>
      <title>Email Verification When You Are One Person</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Wed, 09 Sep 2026 06:00:31 +0000</pubDate>
      <link>https://dev.to/kumard3/email-verification-when-you-are-one-person-12l</link>
      <guid>https://dev.to/kumard3/email-verification-when-you-are-one-person-12l</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/email-verification-for-solo-developers/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Email verification pricing is built around a particular customer: a sales team that has bought a list of 50,000 strangers and needs to know which addresses are real before burning their domain on it. Per-credit pricing, bulk tiers, enterprise plans.&lt;/p&gt;

&lt;p&gt;If you are one person with a signup form, you have a different problem, and most of what that product sells solves something you do not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three different questions
&lt;/h2&gt;

&lt;p&gt;"Verify this email" is really three questions with very different costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it syntactically an address?&lt;/strong&gt; Free, instant, and you should already be doing it. Do not use an elaborate regex. Check for one @, a domain with a dot, and no whitespace. Over-strict validation rejects real addresses, and plus-addressing and newer TLDs are the usual casualties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the domain receive mail at all?&lt;/strong&gt; Free, fast, and this is the highest value check available to you. Look up the domain's MX records. No MX means no mail, ever, and that catches the entire class of typos that matters: &lt;code&gt;gmial.com&lt;/code&gt;, &lt;code&gt;hotmial.com&lt;/code&gt;, a domain that expired. One DNS query, no vendor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this specific mailbox exist?&lt;/strong&gt; This is the expensive one, and it is what you are actually paying vendors for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the third question costs money
&lt;/h2&gt;

&lt;p&gt;Proving a mailbox exists means opening an SMTP conversation with the receiving server and getting as far as RCPT TO without sending anything. It is a normal protocol interaction, and it is also exactly what someone harvesting addresses does, so mail servers defend against it.&lt;/p&gt;

&lt;p&gt;They defend by refusing connections from IP addresses that are not established senders. Residential and most cloud IPs are refused outright. Some providers accept every address regardless, which tells you nothing.&lt;/p&gt;

&lt;p&gt;What a verification vendor sells is a pool of IP addresses with enough reputation that mail servers will still talk to them. That is a real asset and genuinely hard to build. It is not clever code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dangerous failure
&lt;/h2&gt;

&lt;p&gt;If you write this yourself and run it from your laptop, you will get 5xx rejections and it is tempting to record them as "invalid". They are not. A 5xx at RCPT TO from an unestablished IP is the server declining to talk to &lt;em&gt;you&lt;/em&gt;. Marking those addresses invalid deletes real customers from your list.&lt;/p&gt;

&lt;p&gt;Only treat a rejection as a bad address when the response names the recipient, typically a 5.1.x code with text about an unknown user. Everything else is unknown, and unknown is a legitimate answer that any honest verifier should return.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;For a signup form:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Syntax check on the client for immediate feedback.&lt;/li&gt;
&lt;li&gt;MX lookup on the server. Reject with a helpful message when the domain cannot receive mail, and offer the obvious correction for common typos.&lt;/li&gt;
&lt;li&gt;Send a confirmation email. This is the only check that proves both that the address exists and that the person asking controls it, and it costs one email.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need a verification vendor for a signup form. Confirmation email is verification, and it is stronger than anything a vendor can sell you.&lt;/p&gt;

&lt;p&gt;For a list you already have and want to mail, the calculus changes. If it is a few hundred addresses collected through your own signup flow, mail them and let the bounces suppress themselves. If it is tens of thousands of addresses of unknown provenance, verify first, because a 13.8% bounce rate will get your sending account reviewed and the vendor fee is cheaper than that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;Syntax and MX are free and catch most real-world mistakes. Mailbox-level verification needs clean sending IPs, which is a thing you rent rather than build. And if you control the signup, a confirmation email beats all of it.&lt;/p&gt;

</description>
      <category>emailverification</category>
      <category>indie</category>
      <category>guide</category>
    </item>
    <item>
      <title>How to Warm Up a New Sending Domain</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Mon, 07 Sep 2026 18:00:30 +0000</pubDate>
      <link>https://dev.to/kumard3/how-to-warm-up-a-new-sending-domain-1p7n</link>
      <guid>https://dev.to/kumard3/how-to-warm-up-a-new-sending-domain-1p7n</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/warm-up-new-sending-domain/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A domain that has never sent mail is not trusted or untrusted, it is unknown. Receiving servers treat unknown senders cautiously, and the way you move out of that category is by sending mail that people engage with, in gradually increasing volume, consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you send anything
&lt;/h2&gt;

&lt;p&gt;Get authentication right first, because a warm-up on a misauthenticated domain teaches receivers the wrong lesson.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF&lt;/strong&gt;: a TXT record authorising your sending service. Keep it under the 10 DNS lookup limit or it fails silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM&lt;/strong&gt;: the provider gives you a key to publish. Verify it is actually signing by checking a received message's headers, not by trusting the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC&lt;/strong&gt;: start at &lt;code&gt;p=none&lt;/code&gt; with a reporting address so you can see what is happening, then move to quarantine and reject once the reports are clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse DNS and a valid return path&lt;/strong&gt;, if you run your own infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The ramp
&lt;/h2&gt;

&lt;p&gt;Volume roughly doubles every few days, with the exact numbers mattering far less than the shape. A workable four week ramp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Week 1:  50 -&amp;gt; 200 per day
Week 2:  500 -&amp;gt; 2,000 per day
Week 3:  5,000 -&amp;gt; 10,000 per day
Week 4:  20,000+ per day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules matter more than the numbers. &lt;strong&gt;Send every day&lt;/strong&gt;, because a domain that sends 2,000 on Monday and nothing until Friday looks like a burst, not a business. And &lt;strong&gt;never skip a rung&lt;/strong&gt; because you are in a hurry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who you send to matters more than how many
&lt;/h2&gt;

&lt;p&gt;This is the part people miss. During warm-up, send to your &lt;em&gt;most engaged&lt;/em&gt; recipients first, and expand outward. Opens and replies are positive signals. Deletions without opening are neutral at best. Spam complaints are severely negative.&lt;/p&gt;

&lt;p&gt;Order your ramp by engagement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;People who opened or replied in the last 30 days.&lt;/li&gt;
&lt;li&gt;Last 90 days.&lt;/li&gt;
&lt;li&gt;Last 180 days.&lt;/li&gt;
&lt;li&gt;Everyone else, if at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sending to a two year old list on day three of a warm-up will produce bounces and complaints at exactly the moment receivers are deciding who you are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch the right numbers
&lt;/h2&gt;

&lt;p&gt;Bounce rate under 2% and complaint rate under 0.1% are the working targets, not the published limits. The published limits are where enforcement begins, which is far too late to be your alarm.&lt;/p&gt;

&lt;p&gt;Segment by receiving domain too. Gmail, Outlook and Yahoo form independent opinions, and it is normal to be doing fine at one and badly at another. An aggregate number hides that completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  If it goes wrong
&lt;/h2&gt;

&lt;p&gt;If placement drops mid-ramp, go back a step and hold there. Do not push through. The signal you are getting is that you moved faster than trust accumulated, and the only remedy is time at lower volume.&lt;/p&gt;

&lt;p&gt;And if you are ramping because you were forced off a shared domain after an incident, be aware you may be warming a new domain while the underlying provider account still carries the old reputation. Reputation attaches to more than the domain name.&lt;/p&gt;

</description>
      <category>deliverability</category>
      <category>senderreputation</category>
      <category>guide</category>
    </item>
    <item>
      <title>Shared or Dedicated Sending Domain: How to Actually Choose</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:00:06 +0000</pubDate>
      <link>https://dev.to/kumard3/shared-or-dedicated-sending-domain-how-to-actually-choose-17h4</link>
      <guid>https://dev.to/kumard3/shared-or-dedicated-sending-domain-how-to-actually-choose-17h4</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/shared-vs-dedicated-sending-domain/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every email platform offers both. The shared domain works instantly and needs no DNS. The dedicated domain needs SPF, DKIM and DMARC records and a warm-up period. The marketing copy rarely tells you which one you should be on, because the honest answer depends on a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number is your monthly volume
&lt;/h2&gt;

&lt;p&gt;Reputation is built from consistent volume. A receiving mail server forms an opinion about a sending domain by watching it over time. If you send 200 messages a month from your own domain, there is not enough signal for anyone to form a positive opinion, and you get treated as an unknown sender indefinitely.&lt;/p&gt;

&lt;p&gt;Rough guidance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Under a few thousand a month&lt;/strong&gt;: shared domain. You will get better placement riding an established reputation than you will building your own from nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent five figures a month&lt;/strong&gt;: dedicated. There is enough volume to establish and hold a reputation, and you stop being exposed to other senders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything you brand&lt;/strong&gt;: dedicated, regardless of volume. If the from address needs to say your company name, that is a product requirement, not a deliverability one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you are actually buying with a shared domain
&lt;/h2&gt;

&lt;p&gt;You are renting someone else's reputation, and rented reputation is pooled. The other tenants affect your placement. A platform that runs a shared domain well is one that polices who may send under it, because the alternative is that one abusive tenant degrades delivery for everyone.&lt;/p&gt;

&lt;p&gt;Questions worth asking a provider before you rely on their shared domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can a free account send under it, and how much?&lt;/li&gt;
&lt;li&gt;Are addresses on it screened for brand impersonation?&lt;/li&gt;
&lt;li&gt;Is there a per-account ramp, or can a new account send at full volume on day one?&lt;/li&gt;
&lt;li&gt;Is reputation pooled per provider account, or segmented?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A provider that cannot answer these has not thought about the failure mode, and the failure mode lands on you as unexplained spam placement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you take on with a dedicated domain
&lt;/h2&gt;

&lt;p&gt;Your reputation becomes yours. That is the whole point and also the whole risk. A bad month is your bad month, and nobody else's volume dilutes it.&lt;/p&gt;

&lt;p&gt;You also take on the setup. SPF authorises the sending service. DKIM signs the message so it cannot be tampered with in transit. DMARC tells receivers what to do when the first two disagree, and it is the one people skip. A domain with SPF and DKIM but no DMARC record is a domain anyone can spoof with no consequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subdomain most people get wrong
&lt;/h2&gt;

&lt;p&gt;Send bulk mail from a subdomain, not your root domain. If &lt;code&gt;mail.example.com&lt;/code&gt; gets a reputation problem, &lt;code&gt;example.com&lt;/code&gt; is largely insulated, and your invoices and password resets keep arriving. Sending marketing and transactional traffic from the same domain means one bad campaign can stop your password resets.&lt;/p&gt;

&lt;p&gt;Separate them further if the volumes are different in kind: transactional on one subdomain, marketing on another. Receivers form separate opinions, which is exactly what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching later
&lt;/h2&gt;

&lt;p&gt;Moving from shared to dedicated is not a flag flip, it is a warm-up. Plan several weeks. Do not move all traffic at once, and do not start with your least engaged recipients. The next post in this series covers the ramp.&lt;/p&gt;

</description>
      <category>deliverability</category>
      <category>senderreputation</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Recording a Bounce Is Not the Same as Honouring It</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Wed, 02 Sep 2026 18:00:29 +0000</pubDate>
      <link>https://dev.to/kumard3/recording-a-bounce-is-not-the-same-as-honouring-it-2nok</link>
      <guid>https://dev.to/kumard3/recording-a-bounce-is-not-the-same-as-honouring-it-2nok</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/email-suppression-list-enforcement/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Almost every email integration gets the first half right. The provider posts a bounce or a complaint to a webhook, you parse it, you write a row. There is a nice chart in the dashboard showing bounces over time.&lt;/p&gt;

&lt;p&gt;Then someone sends to that address again next week, and it bounces again, and you record a second row.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table is not the defense
&lt;/h2&gt;

&lt;p&gt;A suppression list only does anything if something reads it at send time. If your bounce data exists purely to render a graph, you have built observability, not protection. Every hard bounce you have ever recorded is an address you are still willing to mail.&lt;/p&gt;

&lt;p&gt;The fix is not complicated, but it has to go in the right place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isSuppressed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SendBlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recipient is suppressed: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important word is &lt;em&gt;every&lt;/em&gt;. Find the function that every send path funnels through and put the check there. If you have a single-send route, a batch route, a reply route and an auto-responder, and you add the check to three of them, you have not added the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch sends need their own path
&lt;/h2&gt;

&lt;p&gt;A batch of 100 recipients where 4 are suppressed should send 96 messages, not throw. That means the batch path needs a filter rather than a guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;filterSuppressed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AllRecipientsSuppressed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;suppressed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Report the dropped ones back to the caller. Silently sending to fewer people than they asked for is how you lose their trust in the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to suppress, and what not to
&lt;/h2&gt;

&lt;p&gt;Suppress on &lt;strong&gt;permanent&lt;/strong&gt; failures and on complaints. Do not suppress on transient ones.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hard bounce&lt;/strong&gt; (mailbox does not exist, domain does not exist): suppress permanently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complaint&lt;/strong&gt; (recipient marked it as spam): suppress permanently, and treat it as more serious than a bounce. Complaint thresholds are twenty times tighter than bounce thresholds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft bounce&lt;/strong&gt; (mailbox full, greylisted, temporary failure): do not suppress. Retry. Suppressing these throws away deliverable addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocked by reputation&lt;/strong&gt;: this is about you, not the recipient. Suppressing the address hides your own problem and loses a real contact.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The race nobody plans for
&lt;/h2&gt;

&lt;p&gt;Provider webhooks are fast. Sometimes they arrive before your own database has finished writing the row for the message you just sent. If your handler looks up the message by provider ID and drops the event when there is no match, you will silently lose exactly the bounces that happened quickest, which are disproportionately the hard ones.&lt;/p&gt;

&lt;p&gt;Do not drop unmatched events. Fall back to attributing them by the sending address, or queue them for a retry. An unmatched bounce is still a bounce.&lt;/p&gt;

&lt;h2&gt;
  
  
  One check worth writing
&lt;/h2&gt;

&lt;p&gt;The regression that matters is not "does suppression work", it is "does every path use it". A test that enumerates your send routes and asserts each one refuses a suppressed recipient will outlive any individual bug fix.&lt;/p&gt;

</description>
      <category>deliverability</category>
      <category>bouncerate</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
