<?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: Somesh Bhardwaj</title>
    <description>The latest articles on DEV Community by Somesh Bhardwaj (@devsomesh).</description>
    <link>https://dev.to/devsomesh</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%2F3231175%2F21afff20-295d-4021-9d64-00241c858e91.jpeg</url>
      <title>DEV Community: Somesh Bhardwaj</title>
      <link>https://dev.to/devsomesh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devsomesh"/>
    <language>en</language>
    <item>
      <title>Six agents were running and I could not tell you what any of them did</title>
      <dc:creator>Somesh Bhardwaj</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:50:08 +0000</pubDate>
      <link>https://dev.to/devsomesh/six-agents-were-running-and-i-could-not-tell-you-what-any-of-them-did-1a47</link>
      <guid>https://dev.to/devsomesh/six-agents-were-running-and-i-could-not-tell-you-what-any-of-them-did-1a47</guid>
      <description>&lt;p&gt;Six coding agents were running. I could not tell you what any of them had&lt;br&gt;
done.&lt;/p&gt;

&lt;p&gt;Not roughly. Not approximately. The output was there, the files had changed,&lt;br&gt;
and the honest answer to "which one did that" was a shrug. Three questions in&lt;br&gt;
particular had no answer: which run burned the tokens, whether they genuinely&lt;br&gt;
ran at the same time or merely started together, and whether two of them had&lt;br&gt;
quietly edited the same file.&lt;/p&gt;

&lt;p&gt;That last one is the expensive question. An agent working on the wrong file&lt;br&gt;
looks exactly like an agent working on the right one, right up until you read&lt;br&gt;
the diff.&lt;/p&gt;
&lt;h2&gt;
  
  
  The thing that was already true
&lt;/h2&gt;

&lt;p&gt;Every one of those runners writes a transcript to disk while it works. Claude&lt;br&gt;
Code does. So do Cursor, Codex, Gemini CLI, Copilot CLI and Kiro. The record&lt;br&gt;
of what happened was sitting in my home directory the entire time, in six&lt;br&gt;
different formats, none of which I had ever looked at.&lt;/p&gt;

&lt;p&gt;So runlanes does not wrap anything. There is no SDK, no instrumentation step,&lt;br&gt;
no account, and nothing to start before the run starts. It reads what the&lt;br&gt;
runner already wrote.&lt;/p&gt;

&lt;p&gt;The consequence is the part I did not expect to matter as much as it does:&lt;br&gt;
&lt;strong&gt;it works on runs that already finished.&lt;/strong&gt; Most tools in this space need you&lt;br&gt;
to have decided, in advance, that this particular run was worth watching. This&lt;br&gt;
one can answer a question you only thought to ask afterwards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx runlanes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That opens a console on 127.0.0.1:4180 for whatever project you are standing&lt;br&gt;
in. There is no configuration file to write first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F07atw1ptq5p942vn8yoj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F07atw1ptq5p942vn8yoj.webp" alt="The runlanes console"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually shows
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Now&lt;/strong&gt; is every live session, across every runner it found, with what the&lt;br&gt;
main conversation spent against what it handed to subagents. On the session&lt;br&gt;
that motivated the whole thing, that split was 8.3 million tokens of&lt;br&gt;
conversation against 2.1 million delegated, which was not the ratio I would&lt;br&gt;
have guessed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhn1r8md6l2mbsbhkegrg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhn1r8md6l2mbsbhkegrg.webp" alt="The Now tab: live sessions, spend, context and parallelism"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The parallelism figure is the one I keep coming back to. Peak concurrency was&lt;br&gt;
four agents. The share of elapsed time where anything genuinely overlapped was&lt;br&gt;
&lt;strong&gt;9%&lt;/strong&gt;. Four agents were running, and for 91% of the wall clock they were&lt;br&gt;
politely taking turns. Nobody reports that number, and it changes how you plan&lt;br&gt;
a fan-out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graph&lt;/strong&gt; links runs to the files that more than one of them touched.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2zdhkxvrc1xc8591nud7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2zdhkxvrc1xc8591nud7.webp" alt="The shared-files graph"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The distinction that makes this worth having: an edge exists because a run&lt;br&gt;
opened that file. Not because a prompt said it would. Those are different&lt;br&gt;
claims, and only one of them is evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;History&lt;/strong&gt; is every session found for the project, with spend and agent time.&lt;br&gt;
&lt;strong&gt;Docs&lt;/strong&gt; is the plans, skills and agent instructions sitting beside the&lt;br&gt;
project, which turn out to be the thing you most want to read when a run has&lt;br&gt;
gone sideways.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qhiebt892ch6gljjcaa.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qhiebt892ch6gljjcaa.webp" alt="The Docs tab"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusing to make numbers up
&lt;/h2&gt;

&lt;p&gt;Kiro bills in credits, not tokens. Its transcripts record which files were&lt;br&gt;
touched and which documents were read, and no usage at all.&lt;/p&gt;

&lt;p&gt;The console shows a dash.&lt;/p&gt;

&lt;p&gt;It would be trivially easy to show &lt;code&gt;0&lt;/code&gt; there. It would also be a lie of the&lt;br&gt;
most ordinary kind, the sort that makes a dashboard feel authoritative and&lt;br&gt;
quietly poisons every total on the page. An unmeasured run is not a free run.&lt;br&gt;
It is left out of the sums rather than counted as nothing.&lt;/p&gt;

&lt;p&gt;The same instinct runs through the token arithmetic. Tokens counted are input&lt;br&gt;
plus output plus cache creation. Cache &lt;em&gt;reads&lt;/em&gt; are deliberately excluded,&lt;br&gt;
because they re-report the entire prompt on every single turn, and summing&lt;br&gt;
them across a session counts the same context dozens of times and produces a&lt;br&gt;
number several times larger than anything that happened. They are still&lt;br&gt;
included in cost, because they are still billed. Those are two different&lt;br&gt;
questions and the tool answers them separately.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhwpv8wg3y8d39frsylgt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhwpv8wg3y8d39frsylgt.webp" alt="The History tab"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The claims are tests, not sentences
&lt;/h2&gt;

&lt;p&gt;runlanes reads local files, makes no outbound network calls, and binds to&lt;br&gt;
127.0.0.1. Every tool in this category says something like that in its README.&lt;/p&gt;

&lt;p&gt;The difference here is that &lt;strong&gt;CI fails the build if any of it stops being&lt;br&gt;
true.&lt;/strong&gt; There is a step that greps the source for &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;axios&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;https.request&lt;/code&gt; and fails if it finds one. A step that asserts 127.0.0.1&lt;br&gt;
still appears in the server. A step that fails if a runtime dependency ever&lt;br&gt;
appears, which is how the dependency count stays at zero rather than&lt;br&gt;
aspirationally low. And one that fails if a vendor name leaks outside the&lt;br&gt;
adapter directory, which is what keeps six runners from turning into six&lt;br&gt;
special cases scattered through the app.&lt;/p&gt;

&lt;p&gt;A README claim is a sentence somebody wrote once. A CI step is a claim that&lt;br&gt;
has to survive every commit. I would rather ship the second kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the package came from
&lt;/h2&gt;

&lt;p&gt;Published from GitHub Actions through OIDC trusted publishing, so npm records&lt;br&gt;
the commit, the workflow and the runner that built the tarball. &lt;code&gt;npm audit&lt;br&gt;
signatures&lt;/code&gt; reports a verified attestation, and anyone can check that what is&lt;br&gt;
on npm matches what is on GitHub. Sigstore signs it with a certificate that&lt;br&gt;
lives about ten minutes and files the record in a public transparency log, so&lt;br&gt;
there is no long-lived signing key to steal.&lt;/p&gt;

&lt;p&gt;For a tool whose entire argument is "these numbers survive checking", it&lt;br&gt;
seemed inconsistent to ask anyone to take the tarball on faith.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo is the product
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;runlanes --export&lt;/code&gt; writes the whole console to a single self-contained HTML&lt;br&gt;
file. The &lt;a href="https://dev-somesh.github.io/runlanes/" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; is that export,&lt;br&gt;
rebuilt by CI whenever the source changes. It is not a screenshot and not a&lt;br&gt;
mock, which means a change that breaks the console breaks the demo in CI&lt;br&gt;
before it reaches anybody.&lt;/p&gt;

&lt;p&gt;The demo also says in its first line that its runs are staged. A tool arguing&lt;br&gt;
that its numbers can be checked should not open with invented data presented&lt;br&gt;
as real.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was called something else
&lt;/h2&gt;

&lt;p&gt;This shipped as "agenttrace" for about a fortnight. Then I looked properly,&lt;br&gt;
and five published CLIs already install a binary by that name, one of them&lt;br&gt;
with a near-copy of my own opening line in its README.&lt;/p&gt;

&lt;p&gt;The registry entry was still free. Taking it would have meant winning the&lt;br&gt;
package name while losing the command name, the search result and the&lt;br&gt;
repository name, which is losing three arguments to win one.&lt;/p&gt;

&lt;p&gt;"runlanes" is also just a better description. The timeline draws one lane per&lt;br&gt;
run, and whether those lanes overlap is the entire question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx runlanes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT, Node 18 and up, zero runtime dependencies, 95 tests running on Node 18,&lt;br&gt;
20 and 22. The &lt;a href="https://github.com/Dev-Somesh/runlanes" rel="noopener noreferrer"&gt;source is on GitHub&lt;/a&gt;&lt;br&gt;
and the &lt;a href="https://www.npmjs.com/package/runlanes" rel="noopener noreferrer"&gt;package is on npm&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run more than one agent at a time, the data is already on your disk.&lt;br&gt;
This just reads it.&lt;/p&gt;

</description>
      <category>developertools</category>
      <category>aiagents</category>
      <category>observability</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Routing email into Slack, which is not the same as forwarding it</title>
      <dc:creator>Somesh Bhardwaj</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:50:05 +0000</pubDate>
      <link>https://dev.to/devsomesh/routing-email-into-slack-which-is-not-the-same-as-forwarding-it-19mk</link>
      <guid>https://dev.to/devsomesh/routing-email-into-slack-which-is-not-the-same-as-forwarding-it-19mk</guid>
      <description>&lt;p&gt;Every organisation that works in Slack still has email arriving somewhere&lt;br&gt;
else. Vendor notifications, form submissions, the address a partner replies&lt;br&gt;
to. The work is in one place and a meaningful slice of the information about&lt;br&gt;
it is in another, which someone checks when they remember.&lt;/p&gt;

&lt;p&gt;The obvious fix is forwarding. Point the mailbox at a Slack channel and let&lt;br&gt;
the integration post everything. It takes an afternoon, and it fails in a way&lt;br&gt;
that is worth describing precisely, because the failure is not "it did not&lt;br&gt;
work". It is that it worked exactly as specified and made things worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What forwarding actually produces
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Truncated bodies.&lt;/strong&gt; The integration posts a preview. The part of the email&lt;br&gt;
that says what to do is below the fold, so every message becomes a link to the&lt;br&gt;
thing you actually needed, and the channel is a table of contents for an inbox&lt;br&gt;
people are still opening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reply chains, repeatedly.&lt;/strong&gt; A thread with six replies does not arrive as one&lt;br&gt;
conversation. It arrives as six posts, each quoting all the previous ones, so&lt;br&gt;
the channel fills with the same text at increasing lengths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-replies.&lt;/strong&gt; Out-of-office, delivery receipts, no-reply confirmations.&lt;br&gt;
None of it is work and all of it arrives with the same weight as the message&lt;br&gt;
from a partner asking a real question.&lt;/p&gt;

&lt;p&gt;The predictable outcome is that the channel gets muted, and now the&lt;br&gt;
information is in two places neither of which anyone is reading.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx66vd4inmyq6kslgnnjp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx66vd4inmyq6kslgnnjp.png" alt="Diagram" width="799" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing, not forwarding
&lt;/h2&gt;

&lt;p&gt;The distinction that makes this work: a router decides what belongs in Slack&lt;br&gt;
and in what shape, rather than moving everything and hoping the reader&lt;br&gt;
filters. Concretely that means four things the forwarding integration does&lt;br&gt;
not do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplication on the RFC Message-ID.&lt;/strong&gt; Every email carries a globally unique&lt;br&gt;
&lt;code&gt;Message-ID&lt;/code&gt; header, and replies carry &lt;code&gt;In-Reply-To&lt;/code&gt; and &lt;code&gt;References&lt;/code&gt; pointing&lt;br&gt;
back at what they answer. That is the correct identity for a message, rather&lt;br&gt;
than a hash of subject and sender, which collides on exactly the automated&lt;br&gt;
mail you receive most. The router keeps a seven-day lookback of what it has&lt;br&gt;
already posted, which is long enough to cover a thread going quiet over a&lt;br&gt;
weekend and short enough that the state stays small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thread and reply detection.&lt;/strong&gt; Because the headers say what answers what, a&lt;br&gt;
reply can be posted into the Slack thread of the message it answers instead of&lt;br&gt;
starting a new one. The conversation in Slack has the shape of the&lt;br&gt;
conversation in the mailbox, which is the whole point and is the thing&lt;br&gt;
forwarding cannot do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Body extraction rather than preview.&lt;/strong&gt; Multipart MIME, quoted history,&lt;br&gt;
signature blocks and tracking pixels are all separable from the sentence&lt;br&gt;
somebody actually wrote. Extracting that means the Slack message is the&lt;br&gt;
message, and nobody has to open the mail client to find out what was being&lt;br&gt;
asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Noise suppression.&lt;/strong&gt; Auto-replies and no-reply senders are identifiable&lt;br&gt;
before posting, not after. They are dropped rather than muted, because a muted&lt;br&gt;
channel is a solved problem that will unsolve itself the moment the mute is&lt;br&gt;
lifted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail that decides whether people trust it
&lt;/h2&gt;

&lt;p&gt;The router processes in controlled batches and &lt;strong&gt;preserves unread state&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That sounds like an implementation footnote and it is the reason the system is&lt;br&gt;
used. If reading the mailbox programmatically marks messages read, the router&lt;br&gt;
has silently taken over as the only reader, and the person who owns that&lt;br&gt;
inbox has lost the ability to work it directly. They will not say this&lt;br&gt;
explicitly. They will just stop relying on it, because something is now&lt;br&gt;
touching their mail in a way they did not ask for and cannot see.&lt;/p&gt;

&lt;p&gt;An automation that changes state it was not asked to change is not a&lt;br&gt;
convenience. Leaving the mailbox exactly as it found it is what makes the&lt;br&gt;
router additive rather than a takeover, and it is the difference between a&lt;br&gt;
tool people keep and a tool people quietly route around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits now
&lt;/h2&gt;

&lt;p&gt;Operational email is owned in Slack, where the team already works, rather than&lt;br&gt;
in a side inbox somebody checks. Threads look like threads. Auto-replies do&lt;br&gt;
not arrive. The mailbox still works normally for anyone who wants to open it.&lt;/p&gt;

&lt;p&gt;The general principle, which applies well beyond email: moving information&lt;br&gt;
between systems is easy, and almost never the problem. The problem is that the&lt;br&gt;
receiving system has a shape, and information that arrives in the wrong shape&lt;br&gt;
is worse than information that did not arrive, because it costs attention&lt;br&gt;
before it can be ignored.&lt;/p&gt;

</description>
      <category>slack</category>
      <category>gmail</category>
      <category>automation</category>
      <category>operations</category>
    </item>
    <item>
      <title>The message that mentioned finance and tagged nobody</title>
      <dc:creator>Somesh Bhardwaj</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:50:03 +0000</pubDate>
      <link>https://dev.to/devsomesh/the-message-that-mentioned-finance-and-tagged-nobody-3mpg</link>
      <guid>https://dev.to/devsomesh/the-message-that-mentioned-finance-and-tagged-nobody-3mpg</guid>
      <description>&lt;p&gt;Somebody types "can we loop in finance on this?" in a product channel. Nobody&lt;br&gt;
tags the finance channel. The thread moves on. Three weeks later there is a&lt;br&gt;
contract nobody in finance has seen.&lt;/p&gt;

&lt;p&gt;That is not a tooling problem in any obvious sense. Slack worked exactly as&lt;br&gt;
designed. Search would have found the message if anyone had known to look for&lt;br&gt;
it. The failure is that the people who needed to know were never told, and&lt;br&gt;
nothing in the workspace was watching for the difference between mentioning a&lt;br&gt;
team and involving one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why keyword matching does not solve this
&lt;/h2&gt;

&lt;p&gt;The instinct is to grep for the word "finance" and alert on it. That produces&lt;br&gt;
a channel nobody reads inside a week, because "finance" appears in sentences&lt;br&gt;
that have nothing to do with governance, and the sentences that do matter&lt;br&gt;
often do not contain the word at all.&lt;/p&gt;

&lt;p&gt;What actually carries the signal is structure. A Slack message is not plain&lt;br&gt;
text on the wire. When someone references a channel, it arrives looking like&lt;br&gt;
this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Can we loop in &amp;lt;#C01ABCDEF|finance&amp;gt; before this goes out?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a channel &lt;em&gt;reference&lt;/em&gt;, distinct from a mention that notifies the&lt;br&gt;
channel, and it survives in the event payload whether or not anyone was&lt;br&gt;
actually alerted. It means the workspace already knows the difference between&lt;br&gt;
"I said the word finance" and "I pointed at the finance channel and did not&lt;br&gt;
bring anyone in". Nobody was reading it.&lt;/p&gt;

&lt;p&gt;So the bot parses references rather than words. It maps channel IDs to what&lt;br&gt;
those channels are for, and it looks for the specific shape of a message that&lt;br&gt;
points at a governance channel from outside it. Context beats keywords, and&lt;br&gt;
in this case the context was already structured and already being thrown away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjlm48fr54v4d1zi6okvz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjlm48fr54v4d1zi6okvz.png" alt="Diagram"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Two decisions that mattered more than the detection
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It joins every public channel by itself.&lt;/strong&gt; The obvious build asks an admin&lt;br&gt;
to add the bot wherever it should watch, which means coverage is a function of&lt;br&gt;
somebody remembering. Every channel created after launch is a gap, and nobody&lt;br&gt;
finds out until something is missed in one. Instead it lists the public&lt;br&gt;
channels through the API and joins them, then listens for &lt;code&gt;channel_created&lt;/code&gt;&lt;br&gt;
and joins new ones as they appear. Coverage is complete by construction rather&lt;br&gt;
than by diligence, across more than a hundred channels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs on Socket Mode.&lt;/strong&gt; The usual Slack integration takes an inbound&lt;br&gt;
webhook, which means a public HTTPS endpoint that anyone can reach and that&lt;br&gt;
has to be secured, monitored and kept alive. Socket Mode reverses the&lt;br&gt;
direction: the bot opens an outbound connection and Slack pushes events down&lt;br&gt;
it. For an organisation that would otherwise be exposing a new inbound surface&lt;br&gt;
to catch its own internal messages, that trade is worth taking on its own.&lt;/p&gt;
&lt;h2&gt;
  
  
  The constraint that shaped the deployment
&lt;/h2&gt;

&lt;p&gt;Socket Mode has a consequence that only shows up on deploy. Because the bot&lt;br&gt;
never receives inbound HTTP, it never binds a port, and the platform hosting&lt;br&gt;
it was watching for exactly that to decide whether the service was alive. A&lt;br&gt;
correctly working bot looked, to the host, like a process that had failed to&lt;br&gt;
start.&lt;/p&gt;

&lt;p&gt;The fix is a sidecar: a minimal HTTP server inside the same process whose only&lt;br&gt;
job is to answer a health check.&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;import&lt;/span&gt; &lt;span class="nx"&gt;express&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;express&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&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;/&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="nx"&gt;res&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bot is running&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten lines that do nothing for the product and without which the product does&lt;br&gt;
not stay running. It is worth saying plainly, because this is the part that&lt;br&gt;
never appears in an architecture diagram and is most of what deployment&lt;br&gt;
actually is: the platform has an opinion about what a healthy service looks&lt;br&gt;
like, and you either meet it or you do not run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scopes, deliberately narrow
&lt;/h2&gt;

&lt;p&gt;The bot holds &lt;code&gt;channels:join&lt;/code&gt;, &lt;code&gt;channels:history&lt;/code&gt; and &lt;code&gt;chat:write&lt;/code&gt;. Public&lt;br&gt;
channels only, no user impersonation, no private conversations, no ability to&lt;br&gt;
act as anyone. A tool that reads a workspace to improve governance has to be&lt;br&gt;
governable itself, and the smallest scope set that does the job is the one&lt;br&gt;
that survives the security review it will eventually get.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it changed
&lt;/h2&gt;

&lt;p&gt;Coverage went from whichever channels someone remembered to whichever channels&lt;br&gt;
exist. Finance mentions that previously depended on a person noticing now&lt;br&gt;
arrive in one place with the surrounding context attached. The work of&lt;br&gt;
watching moved off people and into something that does not get busy or go on&lt;br&gt;
leave.&lt;/p&gt;

&lt;p&gt;The wider lesson is about where automation should sit. This one requires no&lt;br&gt;
configuration, adapts to new channels on its own, and nobody in the workspace&lt;br&gt;
has to change how they write. The best version of a system like this is one&lt;br&gt;
the team never thinks about, which is also the version that is hardest to&lt;br&gt;
justify building, because the evidence of it working is the absence of an&lt;br&gt;
expensive surprise.&lt;/p&gt;

</description>
      <category>slack</category>
      <category>typescript</category>
      <category>integrations</category>
      <category>governance</category>
    </item>
    <item>
      <title>A small event tool, and the day it stopped being small</title>
      <dc:creator>Somesh Bhardwaj</dc:creator>
      <pubDate>Fri, 13 Mar 2026 12:00:33 +0000</pubDate>
      <link>https://dev.to/devsomesh/how-my-simple-side-project-mutated-into-a-full-blown-saas-orchestration-engine-55jo</link>
      <guid>https://dev.to/devsomesh/how-my-simple-side-project-mutated-into-a-full-blown-saas-orchestration-engine-55jo</guid>
      <description>&lt;p&gt;The task was scoped as a small automation: North America only, one kind of&lt;br&gt;
event, some registrations and a confirmation email. The kind of thing a&lt;br&gt;
spreadsheet and a script would cover.&lt;/p&gt;

&lt;p&gt;It was not that, and the tell came early. The events were multi-session, the&lt;br&gt;
attendees were educators across several timezones, and completion had to&lt;br&gt;
produce a certification that somebody would later rely on. Each of those is&lt;br&gt;
survivable alone. Together they mean state: who registered for which session,&lt;br&gt;
who actually attended, what that leaves outstanding, and what has to happen&lt;br&gt;
next and when. A spreadsheet holds state right up until two things change at&lt;br&gt;
once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54qv6z1nl35wcv9iha2g.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54qv6z1nl35wcv9iha2g.webp" alt="From a simple event tool to an integration-orchestrated platform" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the complexity actually lives
&lt;/h2&gt;

&lt;p&gt;Almost none of it is in the event model. Events, sessions and registrations&lt;br&gt;
are a straightforward schema, and if that were the whole job the original&lt;br&gt;
estimate would have been right.&lt;/p&gt;

&lt;p&gt;The complexity is that nearly every meaningful fact arrives from a system you&lt;br&gt;
do not control. Attendance lives in the video platform. Whether a reminder was&lt;br&gt;
delivered lives in the email platform. Segmentation lives in the marketing&lt;br&gt;
tool. Whether the event is on somebody's calendar depends on an ICS file being&lt;br&gt;
parsed correctly by a client you have never tested.&lt;/p&gt;

&lt;p&gt;Each of those has its own model of a person, its own idea of time, and its own&lt;br&gt;
failure modes. The platform's real work is translation: keeping one coherent&lt;br&gt;
account of what happened while four external systems each report a partial and&lt;br&gt;
occasionally contradictory version of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q49jcfcj1771r96yrid.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q49jcfcj1771r96yrid.webp" alt="The production architecture, layer by layer" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is why the architecture ends up with an explicit translation boundary&lt;br&gt;
rather than external calls scattered through the features. When the video&lt;br&gt;
platform changes what it returns, the change lands in one adapter instead of&lt;br&gt;
in every place attendance is touched. This is the same instinct as any anti&lt;br&gt;
corruption layer: external models do not get to dictate internal ones, because&lt;br&gt;
you will be living with your internal model long after the external one has&lt;br&gt;
been revised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attendance is not a boolean
&lt;/h2&gt;

&lt;p&gt;Zoom knows who joined. It does not know who they are in your system, and the&lt;br&gt;
join records do not reliably line up with your registrations. People join from&lt;br&gt;
a second device. They join late from a phone with a display name their parent&lt;br&gt;
chose. They attend a session they never registered for because a colleague&lt;br&gt;
forwarded the link.&lt;/p&gt;

&lt;p&gt;Automatic matching handles most of it. The decision that mattered was building&lt;br&gt;
a manual match path for the rest, rather than either guessing or dropping&lt;br&gt;
them. A certification is a claim someone will make about themselves later,&lt;br&gt;
possibly to an employer. It should not rest on fuzzy name matching being&lt;br&gt;
confident enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reminders as scheduled work, not delayed jobs
&lt;/h2&gt;

&lt;p&gt;Reminders go out at seven days, twenty-four hours and one hour. The naive&lt;br&gt;
version schedules three delayed jobs per registration at signup and hopes&lt;br&gt;
nothing changes.&lt;/p&gt;

&lt;p&gt;Things change. Sessions move, people cancel, someone registers ninety minutes&lt;br&gt;
before a session starts and needs the one-hour reminder immediately or not at&lt;br&gt;
all. The reliable shape is a background process that periodically evaluates&lt;br&gt;
current state and asks what should be sent now, rather than a queue of&lt;br&gt;
promises made under conditions that no longer hold. It costs more to build and&lt;br&gt;
it survives a rescheduled event, which the delayed-job version does not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc6ie4bovv9imfeynzt8t.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc6ie4bovv9imfeynzt8t.webp" alt="Background processing and the automation layer" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fifteen days, and the conversation about day sixteen
&lt;/h2&gt;

&lt;p&gt;It shipped in about fifteen days as sole engineer and went into production for&lt;br&gt;
real educator cohort trainings, iterated under live testing first: timezone&lt;br&gt;
and ICS handling, mobile, and simplification of an admin UI that had grown to&lt;br&gt;
match the domain rather than the person using it.&lt;/p&gt;

&lt;p&gt;The part worth writing down is what happened next. The work had clearly&lt;br&gt;
outgrown what was contracted, and there were two available responses: absorb&lt;br&gt;
it quietly, or say so. I raised it in writing and we agreed a time-boxed&lt;br&gt;
stabilisation window instead of an open-ended tail.&lt;/p&gt;

&lt;p&gt;That is not a technical decision but it determined the outcome more than most&lt;br&gt;
of the technical ones. Scope creep on a fixed engagement is rarely a single&lt;br&gt;
conversation anybody refused to have. It is a series of small unremarked&lt;br&gt;
absorptions, each individually reasonable, and the project ends in a place&lt;br&gt;
neither side would have chosen deliberately. Naming it early is cheaper for&lt;br&gt;
the client than discovering it late, and it is the difference between a&lt;br&gt;
platform that gets finished and one that gets abandoned in a good-enough&lt;br&gt;
state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell the version of me on day one
&lt;/h2&gt;

&lt;p&gt;The estimate was not wrong about the event tool. It was wrong about how many&lt;br&gt;
systems had to agree for the event tool to be true. If a scope mentions&lt;br&gt;
attendance, certification and reminders in the same sentence, the build is an&lt;br&gt;
integration platform wearing a smaller job's clothes, and the honest estimate&lt;br&gt;
is the one that prices the translation layer rather than the schema.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>integrations</category>
      <category>saas</category>
      <category>scope</category>
    </item>
    <item>
      <title>A portfolio chatbot that can drive the page it lives on</title>
      <dc:creator>Somesh Bhardwaj</dc:creator>
      <pubDate>Fri, 13 Mar 2026 11:38:12 +0000</pubDate>
      <link>https://dev.to/devsomesh/building-an-ai-powered-portfolio-with-react-19-vite-and-gemini-25-flash-3ij2</link>
      <guid>https://dev.to/devsomesh/building-an-ai-powered-portfolio-with-react-19-vite-and-gemini-25-flash-3ij2</guid>
      <description>&lt;p&gt;Most portfolio chatbots are a search box that answers in paragraphs. You ask&lt;br&gt;
what someone has worked on, you get prose, and then you scroll to find it&lt;br&gt;
yourself. The model knows the answer and the page it is sitting on stays&lt;br&gt;
exactly where it was.&lt;/p&gt;

&lt;p&gt;The one on this site can move the page. Ask to see incident work and it opens&lt;br&gt;
the record filtered to incidents. Ask about a section and it scrolls there.&lt;br&gt;
That is not a different model, it is the same tool-calling mechanism everyone&lt;br&gt;
already has, pointed at the interface rather than at a knowledge base.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key stays on the server, and that decides the architecture
&lt;/h2&gt;

&lt;p&gt;The first real decision is not about the model. If the browser calls the model&lt;br&gt;
provider directly, the API key is in the browser, and anybody who opens&lt;br&gt;
devtools has your billing. Every "add AI to your site in five minutes" guide&lt;br&gt;
that keeps the key client-side is teaching a mistake that only shows up on an&lt;br&gt;
invoice.&lt;/p&gt;

&lt;p&gt;So the request goes to a serverless function, which holds the key, attaches&lt;br&gt;
the system instruction and the tool declarations, calls the model, and streams&lt;br&gt;
the answer back. The client sends the conversation and nothing else. That&lt;br&gt;
single constraint determines most of the rest of the design, and it is worth&lt;br&gt;
accepting it up front rather than discovering it later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzl9ckhfsf3vgvxflrpq7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzl9ckhfsf3vgvxflrpq7.png" alt="Diagram" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It has a second effect that is easy to miss: because the system instruction is&lt;br&gt;
assembled server-side, it can be changed without shipping a new bundle, and a&lt;br&gt;
visitor cannot read it. The prompt is not secret, but it is also not something&lt;br&gt;
to hand out with the JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five tools, and what they are for
&lt;/h2&gt;

&lt;p&gt;Four of them do something to the page: search the work, open the record with&lt;br&gt;
an optional category filter, scroll to a section, toggle the theme. One of&lt;br&gt;
them, &lt;code&gt;saveLead&lt;/code&gt;, records that a visitor wants to be contacted.&lt;/p&gt;

&lt;p&gt;The declarations are the interesting part, because they are prompt&lt;br&gt;
engineering wearing a schema. The description on &lt;code&gt;openWork&lt;/code&gt; does not just say&lt;br&gt;
what it does, it says when to reach for it and names the one argument value&lt;br&gt;
that will not work. A tool whose description reads "opens the record" gets&lt;br&gt;
called at the wrong moments; a tool that explains its own preconditions gets&lt;br&gt;
called at the right ones. Most of the behaviour people attribute to the system&lt;br&gt;
prompt is actually in the tool descriptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval that is deliberately not a vector database
&lt;/h2&gt;

&lt;p&gt;Before the model answers, a retrieval step pulls the entries most relevant to&lt;br&gt;
the question and puts them in the prompt. It is a substring matcher over the&lt;br&gt;
record. There are no embeddings and no vector store.&lt;/p&gt;

&lt;p&gt;For a few dozen entries with distinctive names, that is the correct&lt;br&gt;
engineering. Embeddings would add an index to build, a service to depend on,&lt;br&gt;
a rebuild step to forget, and a class of failure where the nearest neighbour&lt;br&gt;
is confidently wrong. The substring matcher is inspectable, and when it&lt;br&gt;
misses, it misses for a reason you can read.&lt;/p&gt;

&lt;p&gt;What it does badly is fail silently. Rename a record or change the category&lt;br&gt;
vocabulary and retrieval quietly returns less, and the bot gets vaguer rather&lt;br&gt;
than erroring. Nothing in the response tells you this happened. That is&lt;br&gt;
precisely why the retrieval layer has tests: not because the matching is&lt;br&gt;
subtle, but because its failure mode is degradation rather than a stack trace,&lt;br&gt;
and degradation does not page anyone.&lt;/p&gt;

&lt;p&gt;The same reasoning covers the couplings between the bot and the site. The&lt;br&gt;
categories in the tool declarations have to match the categories in the data,&lt;br&gt;
and the section names it can scroll to have to match the sections that exist.&lt;br&gt;
Both are asserted in tests, because the alternative is a chatbot confidently&lt;br&gt;
offering to scroll somewhere that was renamed six commits ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lead flow, and the gate before it
&lt;/h2&gt;

&lt;p&gt;The tempting build fires &lt;code&gt;saveLead&lt;/code&gt; the moment it has five fields. That&lt;br&gt;
produces a form wearing a conversation, and it submits while the visitor is&lt;br&gt;
still mid-thought.&lt;/p&gt;

&lt;p&gt;Instead the model has to ask one closing question first, confirming there is&lt;br&gt;
nothing else to add, and only then calls the tool. The reply that follows&lt;br&gt;
carries a request ID, confirms where the transcript was sent, and gives an&lt;br&gt;
address to follow up on with that ID, so the visitor leaves with a reference&lt;br&gt;
rather than a hope.&lt;/p&gt;

&lt;p&gt;That gate is one paragraph in the instruction and it is the difference between&lt;br&gt;
a conversation that captures a lead and an interrogation that happens to be&lt;br&gt;
written in sentences.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;p&gt;Tool calling for interface control rather than only for retrieval. The key on&lt;br&gt;
the server, without exception. The smallest retrieval that works, with tests&lt;br&gt;
around it precisely because it fails quietly. And a persona that positions the&lt;br&gt;
work honestly: the site argues that AI is one tool inside systems work rather&lt;br&gt;
than the identity, and a chatbot that oversold itself would contradict the&lt;br&gt;
page it lives on.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>toolcalling</category>
      <category>netlify</category>
      <category>retrieval</category>
    </item>
  </channel>
</rss>
