<?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>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>
    <item>
      <title>What a 13.8% Bounce Rate Does to an AWS SES Account</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Mon, 31 Aug 2026 18:00:34 +0000</pubDate>
      <link>https://dev.to/kumard3/what-a-138-bounce-rate-does-to-an-aws-ses-account-2g4f</link>
      <guid>https://dev.to/kumard3/what-a-138-bounce-rate-does-to-an-aws-ses-account-2g4f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/ses-bounce-rate-account-under-review/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;AWS SES publishes two thresholds. Above a &lt;strong&gt;5% bounce rate&lt;/strong&gt; your account is placed under review. Above &lt;strong&gt;10%&lt;/strong&gt; sending can be paused entirely. Complaint rate has its own pair, 0.1% and 0.5%. These are measured as a rolling average across the whole account, not per domain, and that last detail is the one that catches people.&lt;/p&gt;

&lt;h2&gt;
  
  
  Account-wide means account-wide
&lt;/h2&gt;

&lt;p&gt;If you have seven verified domains on one SES account, they share a single reputation. One domain sending to a stale list drags the other six down with it. There is no per-domain quarantine and no way to ringfence a bad sender after the fact. The blast radius of a reputation problem is every address you have ever verified on that account.&lt;/p&gt;

&lt;p&gt;This is the single most important thing to understand before you let anyone else send under your infrastructure. If you run a shared sending domain for customers, or a free tier, or a demo, you have pooled every one of those senders' reputations into one number.&lt;/p&gt;

&lt;h2&gt;
  
  
  A high bounce rate is usually not a list hygiene problem
&lt;/h2&gt;

&lt;p&gt;The instinct when the review email lands is to go clean your list. Sometimes that is right. Often it is not, and the distinction matters because cleaning a list you already own does nothing about traffic that has not been sent yet.&lt;/p&gt;

&lt;p&gt;Bounce rate is a ratio. It goes up when bad sends increase, and it also goes up when good sends stop. The three causes worth checking, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Someone is sending to scraped addresses.&lt;/strong&gt; Purchased and scraped lists bounce at rates well north of 20%. If a portion of your traffic is coming from a free tier or a shared domain, check who is sending before you check what.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are not honouring your own bounce feedback.&lt;/strong&gt; If a hard-bounced address can be mailed again next week, every send after the first one is a guaranteed bounce that you generated yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your good traffic dropped.&lt;/strong&gt; A seasonal lull in legitimate volume raises the ratio without a single new bad address.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do in the first hour
&lt;/h2&gt;

&lt;p&gt;Stop the bleeding before you write the appeal. In order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find the sending addresses, not the subjects.&lt;/strong&gt; Subject-line heuristics fall apart the moment the content is not in English. The from address is the reliable signal and it is the one an abuser has to reuse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce suppression at send time.&lt;/strong&gt; Not in a dashboard, in the code path every send funnels through. See &lt;a href="https://dev.to/blog/email-suppression-list-enforcement/"&gt;recording a bounce is not the same as honouring it&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on per-domain attribution.&lt;/strong&gt; SES Virtual Deliverability Manager and configuration set event destinations are things you have to enable &lt;em&gt;before&lt;/em&gt; you need them. If they are off during the incident, AWS holds no per-domain breakdown for that period and you cannot tell from their side which domain caused it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pause the traffic you cannot explain&lt;/strong&gt; rather than the traffic you can.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The appeal
&lt;/h2&gt;

&lt;p&gt;AWS wants three things: what happened, what you changed, and why it will not recur. Specific beats apologetic. "We identified 307 accounts sending to scraped lists, suspended them, and now enforce bounce suppression in the send path with a per-account age-based ramp" is an appeal. "We take deliverability seriously and have cleaned our list" is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the ratio back down
&lt;/h2&gt;

&lt;p&gt;The rate is a rolling average, so it recovers in two ways: bad sends stop, and good sends accumulate. You cannot delete history. Send your most reliable traffic, the transactional mail going to addresses that have engaged recently, and let volume do the rest. Do not try to outrun it with more volume, because if any of that new volume bounces you have made it worse.&lt;/p&gt;

&lt;p&gt;Expect days, not hours. Plan the appeal around that.&lt;/p&gt;

</description>
      <category>deliverability</category>
      <category>ses</category>
      <category>bouncerate</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>Per-User Inbox Provisioning for Multi-Tenant Agent SaaS</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Mon, 15 Jun 2026 18:00:05 +0000</pubDate>
      <link>https://dev.to/kumard3/per-user-inbox-provisioning-for-multi-tenant-agent-saas-2nd1</link>
      <guid>https://dev.to/kumard3/per-user-inbox-provisioning-for-multi-tenant-agent-saas-2nd1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/multi-tenant-inbox-provisioning-agent-saas/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you're building an agent product — personal shopping agents, AI recruiters, autonomous SDRs — every end user will eventually need their own agent, and every agent needs its own inbox. The naive approach (one shared inbox, filter by user) collapses fast. Here's the pattern that scales.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: one inbox per (tenant, agent) pair
&lt;/h2&gt;

&lt;p&gt;For a user running 3 agents, you provision 3 Lumbox inboxes. Each has isolated state, its own OTP stream, its own webhook routing. Tenancy becomes a property of the inbox, not a filter on a shared stream.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;provisionAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agentConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AgentConfig&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;inbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;lumbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inboxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;agentConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;agentConfig&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inboxId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inbox&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;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;agentConfig&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;inbox&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;h2&gt;
  
  
  Webhook routing
&lt;/h2&gt;

&lt;p&gt;One webhook endpoint on your side, Lumbox posts every received email with the inbox's &lt;code&gt;metadata&lt;/code&gt;. You look up which tenant + agent by the &lt;code&gt;userId&lt;/code&gt; in the metadata and dispatch accordingly. No filtering, no risk of cross-tenant leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom domain per tenant
&lt;/h2&gt;

&lt;p&gt;For pro-tier customers, let them bring their own domain. Lumbox supports multi-domain inbox provisioning under a single org — the inbox is created on &lt;code&gt;@theirdomain.com&lt;/code&gt;, but your billing + usage aggregation stays on the single parent org.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lifecycle
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Soft delete&lt;/strong&gt; when a user pauses an agent. Keep the inbox; stop webhook routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard delete&lt;/strong&gt; when they churn. Lumbox purges the inbox and all history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfer&lt;/strong&gt; supported via org migration — inbox stays, billing moves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Billing
&lt;/h2&gt;

&lt;p&gt;Lumbox's metering reports per-inbox usage. Roll it up to tenant-level for your own invoicing, or expose it in a per-user dashboard. No per-tenant infrastructure to run — it's all one API call. &lt;a href="https://lumbox.co" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>multitenant</category>
      <category>saas</category>
      <category>architecture</category>
      <category>agents</category>
    </item>
    <item>
      <title>Web Bot Auth: Signing Outbound Agent Webhooks with HTTP Message Signatures</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Fri, 12 Jun 2026 18:00:29 +0000</pubDate>
      <link>https://dev.to/kumard3/web-bot-auth-signing-outbound-agent-webhooks-with-http-message-signatures-5cc1</link>
      <guid>https://dev.to/kumard3/web-bot-auth-signing-outbound-agent-webhooks-with-http-message-signatures-5cc1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/web-bot-auth-webhook-signing-rfc-9421/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your SaaS sends webhooks. The receiver's endpoint is public. Anyone who discovers the URL can POST to it pretending to be you. Shared HMAC secrets are the common fix, but they require out-of-band key exchange and they don't rotate well. RFC 9421 HTTP Message Signatures — the "Web Bot Auth" pattern — is the better answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Web Bot Auth gives you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You publish an Ed25519 public key at &lt;code&gt;/.well-known/http-message-signatures-directory&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;You sign outbound requests with the matching private key using the &lt;code&gt;Signature-Input&lt;/code&gt; and &lt;code&gt;Signature&lt;/code&gt; headers.&lt;/li&gt;
&lt;li&gt;The receiver fetches your JWKS (once, cache it) and verifies every incoming request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No shared secret. No one-to-one key exchange. Rotate by publishing a new &lt;code&gt;kid&lt;/code&gt; in the JWKS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signature in practice
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Signature-Input: sig1=("@method" "@target-uri" "content-digest" "date");\
    keyid="FOhe9v_YLODSykvK-u7VZ5K1-pI85ZN64swJn1YiLcQ";\
    alg="ed25519";\
    created=1749744000
Signature: sig1=:MEUCIQDX...:
Content-Digest: sha-256=:X48E9qOok...:
Date: Tue, 12 Jun 2026 12:00:00 GMT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Signing side (Node)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sign&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http-message-signatures&lt;/span&gt;&lt;span class="dl"&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;signed&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;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;keyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FOhe9v_YLODSykvK-u7VZ5K1-pI85ZN64swJn1YiLcQ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ed25519&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;components&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@method&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@target-uri&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-digest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verifying side
&lt;/h2&gt;

&lt;p&gt;The receiver fetches your JWKS from &lt;code&gt;/.well-known/http-message-signatures-directory&lt;/code&gt;, caches it, and runs the signature verification. Any tampered body, replayed request (outside the &lt;code&gt;created&lt;/code&gt; window), or wrong &lt;code&gt;keyid&lt;/code&gt; fails verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents especially need this
&lt;/h2&gt;

&lt;p&gt;When an AI agent receives a webhook from a third party, it has no way to know if the payload is legit unless the sender is cryptographically identified. Web Bot Auth is the pattern Cloudflare, Anthropic, and others are converging on for agent-to-agent trust. &lt;a href="https://lumbox.co" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt; signs every outbound webhook with Ed25519 so your agent can trust what arrives.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webhooks</category>
      <category>rfc9421</category>
      <category>webbotauth</category>
    </item>
    <item>
      <title>OAuth for MCP Servers: Authenticating Agents, Not Humans</title>
      <dc:creator>Kumar Deepanshu</dc:creator>
      <pubDate>Tue, 09 Jun 2026 18:00:20 +0000</pubDate>
      <link>https://dev.to/kumard3/oauth-for-mcp-servers-authenticating-agents-not-humans-1jn5</link>
      <guid>https://dev.to/kumard3/oauth-for-mcp-servers-authenticating-agents-not-humans-1jn5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://lumbox.co/blog/mcp-server-oauth-authentication-agents/" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Model Context Protocol spec recommends OAuth 2.1 + Dynamic Client Registration (RFC 7591) for server authentication. In practice, 90% of MCP servers ship with static API keys. That works for single-user local setups. It breaks the moment you want to expose an MCP server to many agents, many users, or a public marketplace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OAuth beats API keys for MCP
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scoped access.&lt;/strong&gt; An agent can request &lt;code&gt;inboxes.read&lt;/code&gt; but not &lt;code&gt;inboxes.delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocable without rotating.&lt;/strong&gt; A compromised token is revoked; the long-lived secret never leaves the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user attribution.&lt;/strong&gt; Every tool call has a user identity. Audit becomes possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic client registration.&lt;/strong&gt; An MCP client (Claude, Cursor) can self-register without a human configuring the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The minimum viable OAuth surface
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; — RFC 8414 metadata.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt; — RFC 9728 linking resource to auth server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/oauth/register&lt;/code&gt; — RFC 7591 Dynamic Client Registration.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/oauth/authorize&lt;/code&gt; — standard OAuth 2.1 authorization code flow with PKCE.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/oauth/token&lt;/code&gt; — token exchange.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Token design for agents
&lt;/h2&gt;

&lt;p&gt;Short-lived access tokens (15 minutes), long-lived refresh tokens (30 days, rotating on use), narrow scopes per tool class. Don't issue a token with &lt;code&gt;*&lt;/code&gt; scope unless the client literally needs every tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lumbox approach
&lt;/h2&gt;

&lt;p&gt;Lumbox's MCP server is moving from API-key auth to full OAuth 2.1 + DCR. When an agent installs &lt;code&gt;@lumbox/mcp-server&lt;/code&gt;, the client auto-registers, pops an OAuth consent screen once, and gets scoped tokens for the tools it asked for. No hand-editing a config file with a key. &lt;a href="https://lumbox.co" rel="noopener noreferrer"&gt;lumbox.co&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>security</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
