<?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: Sid Probstein</title>
    <description>The latest articles on DEV Community by Sid Probstein (@sidswirl).</description>
    <link>https://dev.to/sidswirl</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%2F1132432%2F063b263f-a2ce-4db8-b6ff-b96cf0655d3c.jpeg</url>
      <title>DEV Community: Sid Probstein</title>
      <link>https://dev.to/sidswirl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sidswirl"/>
    <language>en</language>
    <item>
      <title>Get Every Email to Your Domain in One Gmail Inbox, Free, with Cloudflare</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:29:26 +0000</pubDate>
      <link>https://dev.to/sidswirl/get-every-email-to-your-domain-in-one-gmail-inbox-free-with-cloudflare-594o</link>
      <guid>https://dev.to/sidswirl/get-every-email-to-your-domain-in-one-gmail-inbox-free-with-cloudflare-594o</guid>
      <description>&lt;p&gt;You own a domain. Your website lives on it. With Cloudflare's free plan you can make &lt;code&gt;anything@yourdomain.com&lt;/code&gt; - any address, invented on the spot - land in your regular Gmail inbox. No Google Workspace subscription, no mail server, no changing registrars. About 30 minutes, $0.&lt;/p&gt;

&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;catch-all&lt;/strong&gt;: every address at your domain forwards to one inbox.&lt;/li&gt;
&lt;li&gt;Free disposable addresses: give &lt;code&gt;netflix@yourdomain.com&lt;/code&gt; to Netflix and &lt;code&gt;bank@yourdomain.com&lt;/code&gt; to your bank. When spam arrives addressed to one of them, you know who leaked your address.&lt;/li&gt;
&lt;li&gt;Your website, untouched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One limitation: this is &lt;strong&gt;receive-only&lt;/strong&gt;. You can reply &lt;em&gt;as&lt;/em&gt; &lt;code&gt;you@yourdomain.com&lt;/code&gt; from free Gmail via "Send mail as", but the mail goes out through Google's servers without your domain's blessing, so some recipients see "via gmail.com" and strict spam filters may object. If sending from the domain matters, that's what Google Workspace is for. For receiving, read on.&lt;/p&gt;

&lt;p&gt;You need: a domain (registered anywhere; it stays there), a free Cloudflare account, and an inbox to receive the mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Add your domain to Cloudflare
&lt;/h2&gt;

&lt;p&gt;Log in at dash.cloudflare.com, click &lt;strong&gt;Add a domain&lt;/strong&gt;, pick the &lt;strong&gt;Free&lt;/strong&gt; plan.&lt;/p&gt;

&lt;p&gt;Cloudflare scans your existing DNS and imports what it finds. Review this list carefully - the scan is good but not guaranteed complete, and whatever it misses stops resolving after the switch. Check what your DNS really says from any terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short A yourdomain.com
dig +short MX yourdomain.com
dig +short NS yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the MX query returns nothing, you have no existing email service and this migration is pure upside. If it returns something, understand what that mail service is before proceeding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The gotcha - where does your DNS actually live?
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;NS&lt;/code&gt; query tells you who currently answers DNS for your domain, and it is not always your registrar.&lt;/p&gt;

&lt;p&gt;In my case the nameservers pointed at NS1 (&lt;code&gt;*.nsone.net&lt;/code&gt;) - &lt;strong&gt;Netlify DNS&lt;/strong&gt;. My registrar just held the registration; the DNS zone lived in Netlify, from clicking "use Netlify DNS" when connecting my site years earlier. The A records Cloudflare imported were Netlify load balancer IPs, which Netlify rotates. Had I switched nameservers and kept those records, my site would have broken on the next rotation.&lt;/p&gt;

&lt;p&gt;The fix: point at names, not IPs. For a Netlify site (same idea for Vercel, GitHub Pages, and friends):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find your site's default subdomain in the Netlify dashboard (&lt;code&gt;your-site.netlify.app&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;In Cloudflare's DNS page, delete the imported A records.&lt;/li&gt;
&lt;li&gt;Add two CNAMEs, both &lt;strong&gt;DNS only&lt;/strong&gt; (click the orange cloud so it turns grey):

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;www&lt;/code&gt; → &lt;code&gt;your-site.netlify.app&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@&lt;/code&gt; (apex) → &lt;code&gt;your-site.netlify.app&lt;/code&gt; (Cloudflare flattens apex CNAMEs automatically)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DNS-only matters. Your host already runs a CDN and manages your HTTPS certificate; stacking Cloudflare's proxy in front invites redirect loops and cert renewal failures for zero benefit. Grey cloud means Cloudflare only answers DNS. Email Routing doesn't use the proxy, so it works either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Switch nameservers at your registrar
&lt;/h2&gt;

&lt;p&gt;Cloudflare assigns you two nameservers, like &lt;code&gt;ada.ns.cloudflare.com&lt;/code&gt; and &lt;code&gt;bob.ns.cloudflare.com&lt;/code&gt;. At your registrar, replace the current nameservers with those two. The registrar keeps the registration and the renewal bill; it just stops answering DNS questions.&lt;/p&gt;

&lt;p&gt;Propagation is faster than its reputation - the change hits the &lt;code&gt;.com&lt;/code&gt; registry within minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +noall +authority NS yourdomain.com @a.gtld-servers.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When that shows the Cloudflare nameservers, click &lt;strong&gt;Check nameservers now&lt;/strong&gt; on your domain's Cloudflare overview page instead of waiting for its periodic poll. Then load your website. If you did Step 2 right, nothing changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Turn on Email Routing
&lt;/h2&gt;

&lt;p&gt;In the Cloudflare dashboard for your domain: &lt;strong&gt;Email → Email Routing → Get started&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add a destination address&lt;/strong&gt; - your Gmail. Click the link in the verification email Cloudflare sends.&lt;/li&gt;
&lt;li&gt;On &lt;strong&gt;Routing rules&lt;/strong&gt;, enable the &lt;strong&gt;Catch-all&lt;/strong&gt; with action "Send to an email" pointed at your verified destination.&lt;/li&gt;
&lt;li&gt;Accept the DNS records Cloudflare offers to add: three MX records (&lt;code&gt;route1/2/3.mx.cloudflare.net&lt;/code&gt;) that direct your domain's mail to Cloudflare, plus SPF and DKIM records that keep forwarded mail from being spam-flagged. The odd-looking MX priorities are normal.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That single catch-all rule is the whole feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Test it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short MX yourdomain.com
&lt;span class="c"&gt;# 36 route3.mx.cloudflare.net.&lt;/span&gt;
&lt;span class="c"&gt;# 95 route2.mx.cloudflare.net.&lt;/span&gt;
&lt;span class="c"&gt;# 98 route1.mx.cloudflare.net.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send a message from an account &lt;strong&gt;other than&lt;/strong&gt; the destination inbox (Gmail hides mail you send to yourself) to an address you just made up: &lt;code&gt;whatever@yourdomain.com&lt;/code&gt;. It should arrive within seconds. Check spam the first time and hit "Not spam" if needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Receive-only&lt;/strong&gt;, as covered above. Email Routing forwards; it doesn't send or store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch-alls attract spam&lt;/strong&gt; eventually - dictionary-attack spam to &lt;code&gt;random@yourdomain.com&lt;/code&gt; all lands on you. Gmail's filtering handles it, and the per-service trick turns the bug into a feature: add a Cloudflare rule to drop any address that gets burned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your old DNS host's features die at the switch&lt;/strong&gt; - web redirects, parked pages, registrar email forwarding. That's the point, but inventory what you use before you flip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Half an hour, zero dollars, and every address at your domain lands in one inbox.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>website</category>
      <category>free</category>
    </item>
    <item>
      <title>Enterprise source integration as configuration: inside SWIRL's SearchProviders</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Fri, 07 Aug 2026 03:51:36 +0000</pubDate>
      <link>https://dev.to/sidswirl/enterprise-source-integration-as-configuration-inside-swirls-searchproviders-210o</link>
      <guid>https://dev.to/sidswirl/enterprise-source-integration-as-configuration-inside-swirls-searchproviders-210o</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/MrmMAe-YkY8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The expensive part of federated search was never the query fan-out.&lt;br&gt;
It's the N integrations behind it, each with its own API, auth, query&lt;br&gt;
syntax, and response format. SWIRL's answer is to make each integration a configuration object called a SearchProvider, and the 66-second video above walks through one in the admin tool.&lt;/p&gt;

&lt;p&gt;The anatomy, per provider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connection&lt;/strong&gt;: which connector to use (HTTP GET/POST returning JSON,
M365 Graph, SQL, Elastic DSL, ...), the endpoint, and the
authenticator that supplies each user's credentials at query time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query&lt;/strong&gt;: a template that maps SWIRL's query into the source's own
syntax, plus query processors that adapt it automatically (synonyms,
rewrites, source-specific quirks).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results&lt;/strong&gt;: JSONPath-style mappings that normalize each source's
response into a common result shape: title, body, author, date, URL.
This is what makes cross-source ranking honest; every result competes
in the same schema, re-ranked by the same three-pass pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because providers are rows, not code, the workflow is: activate a&lt;br&gt;
preloaded provider (M365, Box, ServiceNow, Elastic, PostgreSQL,&lt;br&gt;
BigQuery, and dozens more ship in the box), add credentials, search.&lt;br&gt;
Change a mapping and the next query uses it; there is no deploy step.&lt;/p&gt;

&lt;p&gt;Example provider JSON: &lt;a href="https://github.com/swirlai/swirl-search/tree/main/SearchProviders" rel="noopener noreferrer"&gt;https://github.com/swirlai/swirl-search/tree/main/SearchProviders&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Guide: &lt;a href="https://docs.swirlaiconnect.com/SP-Guide.html" rel="noopener noreferrer"&gt;https://docs.swirlaiconnect.com/SP-Guide.html&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nocode</category>
      <category>metasearch</category>
      <category>microsoftgraph</category>
    </item>
    <item>
      <title>Replaced Salesforce and HubSpot with Postgres, a Spreadsheet, and an AI Agent</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Tue, 04 Aug 2026 16:00:51 +0000</pubDate>
      <link>https://dev.to/sidswirl/replaced-salesforce-and-hubspot-with-postgres-a-spreadsheet-and-an-ai-agent-5bc9</link>
      <guid>https://dev.to/sidswirl/replaced-salesforce-and-hubspot-with-postgres-a-spreadsheet-and-an-ai-agent-5bc9</guid>
      <description>&lt;p&gt;We have used two real CRMs. &lt;/p&gt;

&lt;p&gt;First Salesforce, because our founding sales person said it was essential. Then HubSpot, because our founding marketing person said it was essential. Both people were right that tracking the pipeline is essential... but really, neither system earned its keep at our scale. &lt;/p&gt;

&lt;p&gt;What the data actually looked like, once it escaped, was a spreadsheet: three columns of company names labeled Engaged Prospects, Legal, and Partners/Channels. &amp;gt;100 organizations, no statuses, no activity history, no next steps.&lt;/p&gt;

&lt;p&gt;A full CRM had twice proven to be overkill; the spreadsheet was underkill.&lt;/p&gt;

&lt;p&gt;So I replaced both with a mini-CRM built in an afternoon with Claude Cowork, using tools we already run: local PostgreSQL, the spreadsheet itself, and SWIRL for search. Here is the full build, including the bug we shipped and caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: From three columns to a schema
&lt;/h2&gt;

&lt;p&gt;The agent read the spreadsheet with &lt;code&gt;openpyxl&lt;/code&gt; and proposed a schema built for the actual job, which is tracking activity, not just listing names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'engaged_prospect'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'legal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'partner'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- free-form: active, stalled, won, dead...&lt;/span&gt;
    &lt;span class="n"&gt;notes&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;      &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;phone&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt;        &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;activity_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;activity_type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'note'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- call, email, meeting, demo...&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_step&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_step_due&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design decisions that paid off immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Activities are append-only history&lt;/strong&gt;, not a mutable "last touched" field. You never lose the story of an account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status is free-form text.&lt;/strong&gt; We started with a suggested vocabulary, but the first real update was "dead", which was not on the list. A CHECK constraint on status would have turned a one-word instruction into a schema migration. Constrain the things that break joins (category); leave vocabulary to the humans.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two views do most of the daily work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Latest activity per organization&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;org_latest_activity&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_activity_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_type&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_activity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_activity_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step_due&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_date&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- The daily to-do list&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;followups_due&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step_due&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step_due&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step_due&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step_due&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An idempotent loader script pulled every name out of the three spreadsheet columns and inserted it with the right category. &lt;code&gt;ON CONFLICT (name, category) DO NOTHING&lt;/code&gt; means re-running it after the spreadsheet grows is always safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Make it searchable with SWIRL
&lt;/h2&gt;

&lt;p&gt;We run &lt;a href="https://github.com/swirlai/swirl-search" rel="noopener noreferrer"&gt;SWIRL&lt;/a&gt; for federated search, so the obvious next move was a SearchProvider that folds the CRM into the same search box as everything else.&lt;/p&gt;

&lt;p&gt;First, a denormalized view so a single ILIKE sweep can hit everything worth matching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;crm_search&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_activity_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_activity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_activity_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_activity_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;next_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_step_due&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                     &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' ('&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;')'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                            &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' &amp;lt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'; '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                 &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;org_latest_activity&lt;/span&gt; &lt;span class="n"&gt;la&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the provider. SWIRL's PostgreSQL connector takes a query template with mapped fields:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mini-CRM - PostgreSQL (SQL)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"connector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PostgreSQL"&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;"localhost:5432:crm:&amp;lt;db-user&amp;gt;:&amp;lt;db-password&amp;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;"query_template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"select {fields} from {table} where {field1} ilike '%{query_string}%' or {field2} ilike '%{query_string}%' or {field3} ilike '%{query_string}%' or {field4} ilike '%{query_string}%' or {field5} ilike '%{query_string}%' or {field6} ilike '%{query_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;"query_mappings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fields=*,sort_by_date=updated,table=crm_search,field1=name,field2=notes,field3=last_activity_summary,field4=contacts,field5=category,field6=next_step"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"result_processors"&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="s2"&gt;"MappingResultProcessor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"CosineRelevancyResultProcessor"&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;"result_mappings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"title='{name} ({category} / {status})',body='Last activity: {last_activity_summary} ({last_activity_type}, {last_activity_date}). Next step: {next_step} (due: {next_step_due}). Contacts: {contacts}. Notes: {notes}',date_published=updated"&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;Searching a company name, a status ("won"), a category ("partner"), or a phrase from a call summary all just work, ranked by SWIRL's relevancy pipeline alongside every other source.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;result_mappings&lt;/code&gt; line went through one iteration worth mentioning. The stock SQL-provider pattern is &lt;code&gt;result_mappings: "DATASET"&lt;/code&gt;, which collapses all rows into a single result carrying a table payload. Fine for analytics, wrong for a CRM: you want each account as its own result with its status and next step visible. Switching to explicit template mappings gives every org its own card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Acme Manufacturing (engaged_prospect / active)
Last activity: intro call re search POC (call, 2026-07-15).
Next step: send scoping doc (due: 2026-07-25). ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mapping &lt;code&gt;date_published=updated&lt;/code&gt; also makes date-sorting real instead of "unknown".&lt;/p&gt;

&lt;p&gt;One more detail for the LLM era: SWIRL providers can carry &lt;code&gt;query_instructions&lt;/code&gt; in their config, which is handed to an LLM when the source is queried through SWIRL's assistant or MCP server. Ours documents the full schema, the enumerated category/status values, and five SQL templates (activity history, due follow-ups, pipeline counts). That turns "what needs attention this week?" into real SQL against &lt;code&gt;followups_due&lt;/code&gt; instead of a keyword guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The update loop nobody hates
&lt;/h2&gt;

&lt;p&gt;The first plan for data entry was conversational: the agent walks through each account and asks what happened. That died on contact with reality after one answer. Dictating 100+ updates one at a time is miserable.&lt;/p&gt;

&lt;p&gt;The fix: the spreadsheet stays the editing surface. The agent added a second sheet, "Tracking", one row per organization, pre-filled from the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name | Category | Status | Activity Date | Activity Type | Activity Summary | Next Step | Next Step Due | Notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit any cells, save, run the sync:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Status or Notes differ from the DB: the org is updated.&lt;/li&gt;
&lt;li&gt;Activity Summary is filled in and differs from the org's latest activity: a &lt;strong&gt;new&lt;/strong&gt; activity row is inserted. History accumulates; the spreadsheet only ever shows the latest.&lt;/li&gt;
&lt;li&gt;New name with a category: org created.&lt;/li&gt;
&lt;li&gt;The script never deletes anything, and running it twice in a row is a no-op.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After any bulk change made directly in SQL, the sheet is regenerated from the database so the two never fight.&lt;/p&gt;

&lt;p&gt;For bulk updates, plain English turned out to beat both surfaces. "Mark everything dead except these accounts; add this new deal as won via partner X" became one reviewed transaction. The agent applies it, prints the resulting pipeline counts, and refreshes the sheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: names are not keys
&lt;/h2&gt;

&lt;p&gt;The sync script's first version keyed organizations by name. One company in our data legitimately exists in two categories (it is both a service firm and a channel partner), which is exactly why the table's unique constraint is &lt;code&gt;(name, category)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The script's in-memory state dict silently kept only one of the two rows, and its &lt;code&gt;UPDATE ... WHERE name = X&lt;/code&gt; hit both. Net effect: syncing the sheet resurrected a row that a bulk update had just marked dead.&lt;/p&gt;

&lt;p&gt;It surfaced immediately for one reason: &lt;strong&gt;the sync prints a change report, and we expected zero.&lt;/strong&gt; A refresh-then-sync cycle should be a perfect no-op, and it reported "1 org updated" instead. That single unexpected line of output was the whole detection mechanism. The fix was mechanical (key by name plus category, scope every UPDATE and INSERT the same way), verified by re-running until the no-op was real.&lt;/p&gt;

&lt;p&gt;If your loader is idempotent, "re-run it and demand zero changes" is the cheapest integration test you will ever write.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ended up with
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Postgres database with full activity history, one &lt;code&gt;psql&lt;/code&gt; away.&lt;/li&gt;
&lt;li&gt;A spreadsheet that is now a UI, not a database.&lt;/li&gt;
&lt;li&gt;CRM accounts as first-class results in our federated search, next step and status on the card.&lt;/li&gt;
&lt;li&gt;An LLM-queryable source: schema-aware SQL through SWIRL's MCP server.&lt;/li&gt;
&lt;li&gt;A natural-language admin loop for bulk operations, with SQL you can read before it runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total schema: three tables, three views, zero ORM, and two canceled CRM subscriptions. Claude Cowork wrote the schema, the loaders, the provider, and the bug; the change report caught the bug; the humans just answered questions and edited cells.&lt;/p&gt;

&lt;p&gt;Would this scale to a 50-seat sales team? No, and it is not trying to. For a founder-led pipeline of a hundred accounts, the boring stack is hard to beat: every piece is inspectable, every update is a SQL statement you can read, and the search box already knew where to look.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>crm</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Taught Claude Code to Install, Configure, and Debug SWIRL Enterprise Search Engine</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Mon, 03 Aug 2026 14:49:47 +0000</pubDate>
      <link>https://dev.to/sidswirl/taught-claude-code-to-install-configure-and-debug-swirl-enterprise-search-engine-ohe</link>
      <guid>https://dev.to/sidswirl/taught-claude-code-to-install-configure-and-debug-swirl-enterprise-search-engine-ohe</guid>
      <description>&lt;p&gt;Evaluating enterprise search software has a fixed cost that has nothing to do with the software: stand up a server, read the docs, connect a source, debug the connector, configure an LLM. By the time you see a real answer from your own data, a week is gone.&lt;/p&gt;

&lt;p&gt;We built a Claude Code plugin that compresses that to about an hour, and along the way learned something more interesting than the time savings: a plugin skill is an executable runbook, and the best way to write one is to watch where the agent researches and move that knowledge upstream.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/swirlai/swirl-claude-plugin" rel="noopener noreferrer"&gt;swirl-claude-plugin&lt;/a&gt; is free and Apache-2.0. It gives Claude Code eight guided workflows for &lt;a href="https://github.com/swirlai/swirl-search" rel="noopener noreferrer"&gt;SWIRL&lt;/a&gt;, a federated search + RAG engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude plugin marketplace add swirlai/swirl-claude-plugin
claude plugin &lt;span class="nb"&gt;install &lt;/span&gt;swirl@swirl &lt;span class="nt"&gt;--scope&lt;/span&gt; user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start a new Claude Code session and you have:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Assesses where you are and routes you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Docker or local install, verified with a live search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connect a source using 25+ built-in connectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:connector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Develop a custom connector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:rag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connect an LLM, configure ranking models, stand up cited answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:migrate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Community-to-Enterprise moves and version upgrades&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wire SWIRL's MCP server into Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/swirl:troubleshoot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Diagnose from logs; file a support ticket when diagnosis needs help&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fmk96o6gofeuvni4n2tl3.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%2Fmk96o6gofeuvni4n2tl3.png" alt="The plugin running in Claude Code: /swirl:start detects the local deployment and offers next steps" width="800" height="652"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "an hour" is honest
&lt;/h2&gt;

&lt;p&gt;SWIRL is federated. It queries SharePoint, OneDrive, Box, databases, and web APIs where they live, re-ranks results with local models, and feeds the best passages to the LLM you configure. Nothing is copied, ingested, or indexed; there is no vector database to build.&lt;/p&gt;

&lt;p&gt;That is why the evaluation is fast: the usual multi-day step (moving your data into the search engine) does not exist. Claude installs the stack, connects two or three sources, and you are looking at ranked, cited answers from your own documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills are runbooks, not code
&lt;/h2&gt;

&lt;p&gt;There is no code in the plugin. Each command is a skill: a markdown file whose frontmatter description routes the request and whose body loads focused operational knowledge into Claude's context. The install skill knows the real Docker quick start for each edition and insists on verifying with a live search before declaring success. The troubleshoot skill embodies a support rule we enforce on ourselves: the user's report is data; find the log line before proposing a fix. The skills also encode what NOT to do: never print API keys, never paste secrets into chat, never restart a stack someone is demoing on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The development loop that actually improves it
&lt;/h2&gt;

&lt;p&gt;Here is the pattern we now use for every release, with a real example.&lt;/p&gt;

&lt;p&gt;During a walkthrough, a user asked Claude to "enable the cross-encoder and use larger embeddings instead of spaCy." Claude did what a capable agent does with missing knowledge: it researched. A dozen commands into settings files and compiled modules, the user steered it: ranking models in SWIRL are configured as &lt;strong&gt;AI Providers&lt;/strong&gt;, in the database, like every other model choice. Once pointed there, Claude flipped the reader role to &lt;code&gt;mxbai-embed-large&lt;/code&gt; served by the stack's own Ollama sidecar, verified the cross-encoder was already active in each result's explain output, and confirmed the change took effect with no restart.&lt;/p&gt;

&lt;p&gt;That transcript became the next release. The rag skill now states it outright: ranking models are AI Providers; do not hunt through settings or source. The cross-encoder ships on by default, so confirm it rather than configure it. The embedding model is whatever provider holds the &lt;code&gt;reader&lt;/code&gt; role. Changes apply at query time.&lt;/p&gt;

&lt;p&gt;The general form: &lt;strong&gt;watch where your agent researches, then move that knowledge into the skill.&lt;/strong&gt; Research is the tax an agent pays for what the skill does not say. Every walkthrough transcript is a free curriculum; the diff between "what the agent had to discover" and "what it should have known" is exactly the next version of the skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are building your own plugin
&lt;/h2&gt;

&lt;p&gt;Three practices that will save you real time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Give the plugin an explicit identity.&lt;/strong&gt; Ship a &lt;code&gt;.claude-plugin/plugin.json&lt;/code&gt; with the plugin's name and version, and keep component definitions (skills, commands) in exactly one place. Ambiguity here surfaces on the user's machine, not yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the exact path a stranger will run, from a clean environment.&lt;/strong&gt; &lt;code&gt;HOME=$(mktemp -d) claude plugin marketplace add you/your-repo &amp;amp;&amp;amp; claude plugin install ...&lt;/code&gt;, then verify the user-visible surface: the actual command names in a session, not just "installed." Manifest validators check syntax, not experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the description for routing.&lt;/strong&gt; The frontmatter description is what decides whether a user's phrasing reaches your skill. When a real user's words missed, we added their words to the description.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The loop closes with MCP
&lt;/h2&gt;

&lt;p&gt;The part we like most: &lt;code&gt;/swirl:mcp&lt;/code&gt; has Claude wire SWIRL's MCP server into itself. The same assistant that installed your knowledge layer then queries it, with permissions enforced server-side per user. Your agents get governed access to enterprise data; the data never moves.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Plugin: &lt;code&gt;claude plugin marketplace add swirlai/swirl-claude-plugin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://docs.swirlaiconnect.com/claude-plugin" rel="noopener noreferrer"&gt;docs.swirlaiconnect.com/claude-plugin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;One-page PDF: &lt;a href="https://docs.swirlaiconnect.com/downloads/SWIRL-Claude-Code-Plugin.pdf" rel="noopener noreferrer"&gt;datasheet&lt;/a&gt;, no registration&lt;/li&gt;
&lt;li&gt;SWIRL Community is open source; want an Enterprise license to evaluate? &lt;a href="https://swirlaiconnect.com/claude/" rel="noopener noreferrer"&gt;Work email in, license out&lt;/a&gt;; no human will contact you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues and PRs welcome on the &lt;a href="https://github.com/swirlai/swirl-claude-plugin" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>mcp</category>
      <category>rag</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Turning Slackbot into an Enterprise Research Agent with MCP</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Tue, 28 Jul 2026 15:04:03 +0000</pubDate>
      <link>https://dev.to/sidswirl/turning-slackbot-into-an-enterprise-research-agent-with-mcp-1c9o</link>
      <guid>https://dev.to/sidswirl/turning-slackbot-into-an-enterprise-research-agent-with-mcp-1c9o</guid>
      <description>&lt;p&gt;We connected a Slack agent to our federated search engine over the Model Context Protocol, then asked it a question only our OneDrive could answer. It searched, read the document, and replied in Slack with the coverage limits in a table and a citation that opens the exact sentence in SharePoint.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/wHR35qlEKTc"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;This post covers the architecture, the wiring, and two gotchas that cost us real debugging time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Three pieces, all standard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A Slack agent that speaks MCP as a client.&lt;/strong&gt; Slack's agent platform lets a bot declare MCP servers in its manifest and connect to them at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SWIRL's MCP server.&lt;/strong&gt; &lt;a href="https://github.com/swirlai/swirl-search" rel="noopener noreferrer"&gt;SWIRL&lt;/a&gt; is a federated search + RAG engine; its MCP server exposes &lt;code&gt;search&lt;/code&gt;, &lt;code&gt;search_rag&lt;/code&gt; (grounded answers with citations), &lt;code&gt;read_document&lt;/code&gt;, &lt;code&gt;score_document&lt;/code&gt;, &lt;code&gt;list_providers&lt;/code&gt;, and &lt;code&gt;chat&lt;/code&gt;. It's a standalone process that calls a running SWIRL deployment over HTTP, so licensing, throttling, and per-user permissions are enforced by SWIRL exactly as for any API client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sources.&lt;/strong&gt; SharePoint, OneDrive, Box, databases, web APIs; SWIRL queries them where they live. Nothing is copied into the bot, the model, or a vector database.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The wiring
&lt;/h2&gt;

&lt;p&gt;On the Slack side, the bot's manifest declares the MCP server and the bot connects with a slash command. On the SWIRL side, the server runs in HTTP transport mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SWIRL_MCP_TRANSPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http &lt;span class="nv"&gt;SWIRL_MCP_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8675 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;SWIRL_MCP_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;api-key&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; swirl_mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the demo we exposed it through a tunnel with auth disabled; do not do that in production. The production path is the server's OAuth 2.1 resource-server mode (&lt;code&gt;SWIRL_MCP_AUTH=oidc&lt;/code&gt;): the MCP host runs PKCE against your IdP, the server validates each bearer JWT (issuer, audience, JWKS signature), and SWIRL maps the token to the real calling user, so every search is permission-trimmed per caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a query looks like
&lt;/h2&gt;

&lt;p&gt;Asked "Search OneDrive for SWIRL's insurance policy", the bot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;calls &lt;code&gt;list_providers&lt;/code&gt; to find the OneDrive source id;&lt;/li&gt;
&lt;li&gt;calls &lt;code&gt;search&lt;/code&gt; scoped to that provider;&lt;/li&gt;
&lt;li&gt;calls &lt;code&gt;search_rag&lt;/code&gt; for a grounded answer over the results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reply carries the policy limits as a table, flags that professional services are excluded from that policy, and includes a &lt;code&gt;text_fragment_url&lt;/code&gt; citation: a deep link that opens the source document scrolled to the exact quoted passage.&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%2F5mvel10db8p2x7t77fez.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%2F5mvel10db8p2x7t77fez.png" alt="The bot listing SWIRL's MCP tools in Slack" width="799" height="651"&gt;&lt;/a&gt;&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%2Fmbucoqt0lwvn3snsikvp.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%2Fmbucoqt0lwvn3snsikvp.png" alt="The cited answer: coverage limits in a table, exclusions flagged, deep link to the source" width="799" height="651"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Timeout mismatch.&lt;/strong&gt; Slack gives an MCP tool call about 60 seconds; SWIRL's &lt;code&gt;search_rag&lt;/code&gt; polls up to 90 seconds for the RAG answer, sized for a cold local model. On a slow model the Slack call dies first and the bot reports a failure while the answer lands seconds later. Fix: fast model for Slack-facing RAG, or lower &lt;code&gt;SWIRL_MCP_RAG_POLL_TIMEOUT&lt;/code&gt; so failures are honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool descriptions are UX.&lt;/strong&gt; The bot's first act in the demo is describing SWIRL's tools, in its own words, accurately. That's not the bot being clever; it's the MCP server's tool descriptions being written for an LLM audience. If your agent misuses a tool, fix the description before touching the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Agents don't need your data ingested into them; they need a governed search layer. The bot never held our documents. It held a connection to a server that could search them, as us, with permissions enforced and citations attached.&lt;/p&gt;

&lt;p&gt;The same MCP server works from Claude Desktop and Claude Code. There's a Claude Code plugin that stands up the whole stack: &lt;a href="https://github.com/swirlai/swirl-claude-plugin" rel="noopener noreferrer"&gt;swirl-claude-plugin&lt;/a&gt;, and the 2-minute demo video is here: [LINK-TO-VIDEO].&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://docs.swirlaiconnect.com" rel="noopener noreferrer"&gt;docs.swirlaiconnect.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>rag</category>
      <category>slack</category>
    </item>
    <item>
      <title>SWIRL 5 is GA: knowledge authority for enterprise AI</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:04:12 +0000</pubDate>
      <link>https://dev.to/sidswirl/swirl-5-is-ga-knowledge-authority-for-enterprise-ai-5anp</link>
      <guid>https://dev.to/sidswirl/swirl-5-is-ga-knowledge-authority-for-enterprise-ai-5anp</guid>
      <description>&lt;p&gt;SWIRL 5 is generally available! I want to use this post to explain what it is at an engineering level, because the one-line pitch ("the knowledge authority layer for enterprise AI") does not tell you how it works or where the hard parts are. I would rather show you the machine.&lt;/p&gt;

&lt;p&gt;Quick disclosure: I wrote the original version of SWIRL and I run the company. So read this as the person who built it explaining the design, not as a neutral survey. I have tried to be honest about the limits, including where a hand-tuned stack matches us.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that changed
&lt;/h2&gt;

&lt;p&gt;A year ago, "federated search across your systems, then re-rank the results" was a product you could sell. Today it is table stakes. MCP turned retrieval into a commodity: any model can reach any system through a connector, and every serious stack ships a re-ranker. If your pitch is "we retrieve and we re-rank," a technical evaluator will point at four other tools that do the same thing by lunch.&lt;/p&gt;

&lt;p&gt;So the interesting question moved. It is no longer "can you find the document." Everything finds the document. It is &lt;strong&gt;"which of the versions you found is the one my organization actually stands behind."&lt;/strong&gt; In a real enterprise corpus the answer to any given query does not exist once. It exists as a draft, three redlines, a copy someone saved to their desktop, and the executed final, all sitting in different systems, all semantically near-identical. Retrieval returns all of them. The model picks one, confidently, and it has no idea which one carries authority.&lt;/p&gt;

&lt;p&gt;That is a governance problem wearing a search costume, and it is what SWIRL 5 is built to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canonical version election
&lt;/h2&gt;

&lt;p&gt;The core primitive is version election. When SWIRL federates a query and gets back a cluster of near-identical documents, it does not just hand the pile to the model. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clusters the versions it found across every source.&lt;/li&gt;
&lt;li&gt;Scores each on signals that actually correlate with authority: &lt;strong&gt;source authority&lt;/strong&gt; (an executed contract in iManage outranks a draft on someone's OneDrive), &lt;strong&gt;naming&lt;/strong&gt; ("Executed", "Final", version numbers), and &lt;strong&gt;recency&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Elects one canonical version, and exposes its reasoning so a human can see why.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On top of that, teams can &lt;strong&gt;pin&lt;/strong&gt; a canonical result for a query directly. Once pinned, every later search and every agent calling SWIRL gets the endorsed answer, full stop. Election is the automatic path; pinning is the human override. Both produce the same thing: a single answer the organization has stood behind, not the model's best guess.&lt;/p&gt;

&lt;p&gt;This is the piece the frontier models cannot do for themselves. Claude and Copilot are extremely good at drafting and summarizing. Neither has any way to know which of your nine versions is ratified, because that fact does not live in the documents. It lives in your organization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranking: three passes, run locally, no vector database
&lt;/h2&gt;

&lt;p&gt;Under the election sits the ranking pipeline. It runs in three passes, and both models run locally in your tenant. Nothing goes over the wire to a third-party ranking service, and there is no vector database to build, secure, or keep in sync.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keyword + BM25.&lt;/strong&gt; Lexical first. Quoted phrases and exact terms are honored as written. In enterprise and legal content this matters: a defined term or a specific clause has to match exactly, not approximately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding re-rank.&lt;/strong&gt; &lt;code&gt;E5-Large-V2&lt;/code&gt; embeddings with title-aware chunking, fused with the lexical scores using reciprocal rank fusion. Semantic recall, without letting it steamroll the exact matches from pass one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder.&lt;/strong&gt; &lt;code&gt;MS-MARCO cross-encoder&lt;/code&gt; reads the query and each candidate document together, as a pair, and scores real relevance rather than vector similarity. It is the expensive pass, which is exactly why it runs last, only on the candidates that survived the first two.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The "no vector database" part is not a slogan, it is a deployment property. There is no second copy of your content sitting in an index that your information-governance policy never contemplated. The documents stay in iManage, Box, SharePoint, wherever they already live. Only the ranking happens in SWIRL. For a regulated buyer, removing that second copy removes an entire category of risk.&lt;/p&gt;

&lt;p&gt;If you want the independent version of this argument, Meta's XetHub benchmarked keyword-only, vector-only, and hybrid re-rank, and hybrid won. Their post was literally titled "you don't need a vector database."&lt;/p&gt;

&lt;h2&gt;
  
  
  Assembly: a bounded prompt, not a context dump
&lt;/h2&gt;

&lt;p&gt;Retrieval and ranking decide what is relevant. Assembly decides what the model actually sees, and that is where the token bill and a lot of the accuracy live.&lt;/p&gt;

&lt;p&gt;SWIRL treats the prompt as a hard budget, around 3,000 tokens, and fills it deliberately: at most 10 sources, only those scoring above a relevance threshold, a topic matcher that scores the spans &lt;em&gt;inside&lt;/em&gt; each source so only the passages that answer the query go in, per-source truncation to fit, and version de-duplication that drops the superseded copies before assembly. One bounded call, not the refine or tree-summarize modes that call the model once per chunk and multiply both tokens and latency.&lt;/p&gt;

&lt;p&gt;The counterintuitive result, which we measured against LangChain and LlamaIndex defaults and checked against their source: in a versioned corpus, sending &lt;em&gt;less&lt;/em&gt; produces a &lt;em&gt;better&lt;/em&gt; answer. More context means more near-identical duplicates, and the model gets less certain, not more. We wrote that study up separately if you want the numbers and the honest caveats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounding checks
&lt;/h2&gt;

&lt;p&gt;Every generated answer is checked against the sources it cited. Claims that are not supported by the retrieved passages are flagged rather than shipped silently. This is not a hallucination cure, nothing is, but it changes the failure mode from "confident and wrong and unmarked" to "flagged for a human." In an enterprise setting that distinction is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it fits your stack
&lt;/h2&gt;

&lt;p&gt;SWIRL 5 is headless and API-first, with a first-class MCP server. Any AI work surface calls it over MCP or REST and gets back ranked, permissioned, canonical answers with citations attached. It runs in your tenant, honors each source system's existing permissions on every query, and works with the model you choose, hosted Claude or GPT, Copilot, or a local model on your own hardware. The agent angle is the one I would watch: a human reading a superseded policy usually catches it, an agent does not pause, so serving agents the &lt;em&gt;approved&lt;/em&gt; answer instead of a raw retrieval is a safety property, not a nicety.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is hard, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version election is heuristic.&lt;/strong&gt; Source authority, naming, and recency get you a long way, but naming conventions are messy and "most recent" is not always "operative." Pinning exists precisely because the automatic signal is not always enough. We show the reasoning so humans can correct it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A carefully hand-tuned vanilla RAG can match our per-call token count.&lt;/strong&gt; Low k, a good reranker, small chunks, plus your own de-dup layer. Our value is delivering that discipline by default and adding cross-version de-duplication that off-the-shelf stacks do not, not magic per-token compression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grounding checks reduce unsupported claims, they do not eliminate them.&lt;/strong&gt; Treat the flag as a prompt for review, not a guarantee.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you are putting AI on your own data, that is exactly the case this is built for. It is generally available now at &lt;a href="https://swirlaiconnect.com" rel="noopener noreferrer"&gt;swirlaiconnect.com&lt;/a&gt;, and I am happy to run it against a slice of your own systems so you can see the ranking, the citations, and the permission boundary on your data rather than a demo corpus. I built it, so you would be talking to the person who wrote the code.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
    </item>
    <item>
      <title>How many tokens does your RAG stack actually send to the LLM?</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:13:00 +0000</pubDate>
      <link>https://dev.to/sidswirl/how-many-tokens-does-your-rag-stack-actually-send-to-the-llm-4hn6</link>
      <guid>https://dev.to/sidswirl/how-many-tokens-does-your-rag-stack-actually-send-to-the-llm-4hn6</guid>
      <description>&lt;p&gt;The token bill for a RAG system is not set by your vector database. It's set one step later, by how you assemble the context you hand the LLM. Retrieval finds candidates; &lt;strong&gt;assembly&lt;/strong&gt; decides how many of them, how much of each, and across how many LLM calls. That's where the money is spent.&lt;/p&gt;

&lt;p&gt;So "vector DB vs. framework X vs. SWIRL" is the wrong axis. The real comparison is between &lt;strong&gt;context-assembly strategies&lt;/strong&gt;. Here's an honest, source-checked look at how many tokens each common approach sends, and where SWIRL 5 actually costs less.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest headline first
&lt;/h2&gt;

&lt;p&gt;SWIRL doesn't win by using smaller chunks. Anyone can lower &lt;code&gt;top_k&lt;/code&gt;. On a single lean query, a minimal config like LlamaIndex's default (&lt;code&gt;top_k=2&lt;/code&gt;) sends &lt;em&gt;fewer&lt;/em&gt; raw tokens than SWIRL. If someone tells you SWIRL "always uses the fewest tokens," a technical evaluator will disprove it in five minutes.&lt;/p&gt;

&lt;p&gt;SWIRL's advantage is &lt;strong&gt;structural&lt;/strong&gt;, and it shows up exactly where enterprise content lives, in corpora full of document &lt;em&gt;versions&lt;/em&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It never spends tokens on duplicate or superseded versions.&lt;/strong&gt; Stock top-k returns whatever is nearest in embedding space, which in a versioned corpus means several near-identical copies. SWIRL collapses them to one canonical document &lt;em&gt;before&lt;/em&gt; the LLM sees them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It always answers in one bounded call.&lt;/strong&gt; The multi-document synthesis modes people reach for when they want quality (LangChain &lt;code&gt;refine&lt;/code&gt;/&lt;code&gt;map_reduce&lt;/code&gt;, LlamaIndex &lt;code&gt;refine&lt;/code&gt;/&lt;code&gt;tree_summarize&lt;/code&gt;) multiply LLM &lt;strong&gt;calls&lt;/strong&gt;, and &lt;code&gt;refine&lt;/code&gt; multiplies &lt;strong&gt;tokens&lt;/strong&gt; super-linearly. SWIRL never pays that tax.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What the common defaults actually do
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Naive vector-DB RAG (Pinecone / Weaviate / Qdrant tutorials)
&lt;/h3&gt;

&lt;p&gt;The pattern is: embed the query → retrieve top-k chunks → stuff them all into one prompt. Typical documented defaults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chunk size &lt;strong&gt;~512 tokens&lt;/strong&gt; (the common "start here"; the band is 256-1024)&lt;/li&gt;
&lt;li&gt;Chunk overlap &lt;strong&gt;10-20%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;top_k&lt;/code&gt; &lt;strong&gt;3-5&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No de-duplication.&lt;/strong&gt; None of the vendor quickstarts add a dedup or diversity step. Overlap alone guarantees adjacent chunks share text, and multiple versions of a document sit in the same embedding neighborhood, so top-k routinely returns redundant context and the pipeline sends all of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Input tokens ≈ &lt;code&gt;k × chunk_tokens + overhead&lt;/code&gt;. At k=5 and ~1,000-token chunks, that's ~5,000 tokens, a large fraction of it redundant.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.pinecone.io/learn/chunking-strategies/" rel="noopener noreferrer"&gt;Pinecone&lt;/a&gt;, &lt;a href="https://weaviate.io/blog/chunking-strategies-for-rag" rel="noopener noreferrer"&gt;Weaviate&lt;/a&gt;, &lt;a href="https://qdrant.tech/documentation/tutorials-build-essentials/rag-deepseek/" rel="noopener noreferrer"&gt;Qdrant&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangChain
&lt;/h3&gt;

&lt;p&gt;Verified from source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retriever default &lt;strong&gt;&lt;code&gt;k=4&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;similarity_search(k=4)&lt;/code&gt; in &lt;a href="https://github.com/langchain-ai/langchain/blob/master/libs/core/langchain_core/vectorstores/base.py" rel="noopener noreferrer"&gt;&lt;code&gt;vectorstores/base.py&lt;/code&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RecursiveCharacterTextSplitter&lt;/code&gt; default &lt;strong&gt;4,000 chars / 200 overlap&lt;/strong&gt; (≈1,000 tokens) (&lt;a href="https://github.com/langchain-ai/langchain/blob/master/libs/text-splitters/langchain_text_splitters/base.py" rel="noopener noreferrer"&gt;&lt;code&gt;text_splitters/base.py&lt;/code&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The combine-documents chains differ enormously in cost:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chain&lt;/th&gt;
&lt;th&gt;LLM calls (N docs)&lt;/th&gt;
&lt;th&gt;Token behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stuff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;all docs in one prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map_reduce&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;N + ≥1&lt;/td&gt;
&lt;td&gt;one call per doc, then reduce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;N, sequential&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;re-sends the &lt;em&gt;growing&lt;/em&gt; answer each step → &lt;strong&gt;super-linear tokens&lt;/strong&gt;, no parallelism&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map_rerank&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;N&lt;/td&gt;
&lt;td&gt;one doc per call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;refine&lt;/code&gt; is the trap: each step re-transmits the accumulating answer plus the next document, so the running answer is re-sent N-1 times and grows as it goes.&lt;/p&gt;

&lt;h3&gt;
  
  
  LlamaIndex
&lt;/h3&gt;

&lt;p&gt;Verified from source (&lt;code&gt;llama-index-core&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;chunk_size&lt;/code&gt; &lt;strong&gt;1,024 tokens&lt;/strong&gt;, &lt;code&gt;similarity_top_k&lt;/code&gt; &lt;strong&gt;2&lt;/strong&gt;, default response mode &lt;strong&gt;&lt;code&gt;compact&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://github.com/run-llama/llama_index/blob/main/llama-index-core/llama_index/core/constants.py" rel="noopener noreferrer"&gt;&lt;code&gt;constants.py&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/run-llama/llama_index/blob/main/llama-index-core/llama_index/core/response_synthesizers/factory.py" rel="noopener noreferrer"&gt;&lt;code&gt;factory.py&lt;/code&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No default de-duplication.&lt;/strong&gt; Node postprocessors are opt-in and the default similarity cutoff is off, so two versions of a doc in top-k both go to the LLM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Response modes (&lt;a href="https://developers.llamaindex.ai/python/framework/module_guides/deploying/query_engine/response_modes/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;LLM calls&lt;/th&gt;
&lt;th&gt;Token behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;compact&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;~1&lt;/td&gt;
&lt;td&gt;packs nodes into as few prompts as fit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;N&lt;/td&gt;
&lt;td&gt;one call per node, re-sends evolving answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tree_summarize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;gt;1, recursive&lt;/td&gt;
&lt;td&gt;summarize groups, then summaries-of-summaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accumulate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;N&lt;/td&gt;
&lt;td&gt;query each node separately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;compact&lt;/code&gt; with &lt;code&gt;top_k=2&lt;/code&gt; is genuinely lean, but that's a recall trade-off, and it still sends duplicate versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SWIRL 5 does (verified in source)
&lt;/h2&gt;

&lt;p&gt;SWIRL treats the LLM prompt as a hard budget and fills it deliberately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hard prompt budget&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RAG prompt capped at ~&lt;strong&gt;3,000 tokens&lt;/strong&gt; (&lt;code&gt;SWIRL_RAG_TOK_DEFAULT&lt;/code&gt;); assembly stops when full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Source cap + relevance gate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;≤ &lt;strong&gt;10&lt;/strong&gt; sources, only those scoring &lt;strong&gt;≥ 0.8&lt;/strong&gt; are eligible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scored semantic chunks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a BM25 topic-matcher scores spans &lt;em&gt;within&lt;/em&gt; each source; on overflow SWIRL narrows to those scored spans, not the whole chunk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Truncation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;per-source token-by-token truncation to fit the budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Markup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;relevant spans wrapped in &lt;code&gt;&amp;lt;SW-IMPORTANT&amp;gt;…&amp;lt;/SW-IMPORTANT&amp;gt;&lt;/code&gt; plus a compact per-source metadata header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;De-dup before the LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;version-cluster alternates dropped; if a doc is pinned, only the canonical is kept, so N versions collapse to 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Single call&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one stuff-style synthesis call (worst case +1 JSON repair)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Net: &lt;strong&gt;~3,000 input tokens, one call, zero redundant-version tokens&lt;/strong&gt;, a predictable ceiling that doesn't grow with document size or corpus size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting numbers on it
&lt;/h2&gt;

&lt;p&gt;Scenario: a query over a corpus where the relevant policy exists in &lt;strong&gt;5 versions&lt;/strong&gt;, plus 3 other relevant documents, a realistic enterprise shape. Total LLM &lt;strong&gt;input tokens per query&lt;/strong&gt; (summed across calls):&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%2F381ph9cic1pffq8tcuuw.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%2F381ph9cic1pffq8tcuuw.png" alt="Tokens sent to the LLM to answer one query: SWIRL vs. LangChain, LlamaIndex, naive vector RAG" width="800" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading it honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;vs. a &lt;strong&gt;deliberately minimal&lt;/strong&gt; stuff config (LlamaIndex &lt;code&gt;compact&lt;/code&gt;, &lt;code&gt;top_k=2&lt;/code&gt;): SWIRL is &lt;em&gt;comparable&lt;/em&gt; per call, but that config buys its low count with poor recall and still ships duplicate versions.&lt;/li&gt;
&lt;li&gt;vs. &lt;strong&gt;typical&lt;/strong&gt; stuff RAG (k=5, ~1k chunks): SWIRL is &lt;strong&gt;~40-45% fewer input tokens&lt;/strong&gt;, and it removes the redundant-version half entirely.&lt;/li&gt;
&lt;li&gt;vs. the &lt;strong&gt;quality-oriented multi-call modes&lt;/strong&gt; (&lt;code&gt;refine&lt;/code&gt;, &lt;code&gt;tree_summarize&lt;/code&gt;, &lt;code&gt;map_reduce&lt;/code&gt;): SWIRL is a &lt;strong&gt;2-3× reduction&lt;/strong&gt; in input tokens, and a larger reduction in &lt;strong&gt;output&lt;/strong&gt; tokens, since those modes generate once per call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the advantage is real, and where it isn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real and hard to get off-the-shelf:&lt;/strong&gt; cross-&lt;strong&gt;version&lt;/strong&gt; de-duplication. None of these frameworks do it by default. In a versioned corpus it's the difference-maker, and it compounds: the more versions, the more SWIRL saves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real:&lt;/strong&gt; one bounded call vs. N-call synthesis; a predictable cost ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest caveat:&lt;/strong&gt; a hand-tuned vanilla RAG (low &lt;code&gt;k&lt;/code&gt;, a good reranker, small chunks, &lt;code&gt;stuff&lt;/code&gt;, plus your own dedup layer) &lt;em&gt;can&lt;/em&gt; match SWIRL's per-call token count. SWIRL's value is delivering that discipline &lt;strong&gt;by default&lt;/strong&gt;, and adding version de-dup the others lack. It isn't magic per-token compression.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you're answering questions over a corpus with real document versioning, the tokens you waste aren't in chunk size - they're in &lt;strong&gt;sending the LLM the same document five times&lt;/strong&gt; and in &lt;strong&gt;synthesis modes that call the model once per chunk&lt;/strong&gt;. SWIRL 5's design removes both by default. Measure your own stack the same way: total input &lt;em&gt;and&lt;/em&gt; output tokens per query, summed across every LLM call. That number, not &lt;code&gt;top_k&lt;/code&gt;, is your bill.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>Links + Snippets Not Enough for RAG</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Mon, 06 Jul 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/sidswirl/links-snippets-not-enough-for-rag-4mni</link>
      <guid>https://dev.to/sidswirl/links-snippets-not-enough-for-rag-4mni</guid>
      <description>&lt;p&gt;Several people posting lately about how RAG + search is not enough:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"All the model gets is a list of links and snippets. It's not enough to make sense of most real business questions."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Agreed. If your pipeline stuffs ten search snippets into a prompt and hopes, you get confident mush. A snippet is a pointer to evidence, not the evidence. The fix is a step most RAG setups skip: page fetch plus a reader. Three parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Fetch the page, not the snippet
&lt;/h2&gt;

&lt;p&gt;The search result is a pointer. So before generating, SWIRL fetches the actual page or document behind each top hit. Now the pipeline is working from the full source, not the 200 characters a search API happened to return.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Read it with a reader LLM
&lt;/h2&gt;

&lt;p&gt;This is the part that matters. Some model reads each fetched page against the question and pulls out the passages that actually answer it, marking and scoring them. The chaff never reaches the expensive answering model. &lt;/p&gt;

&lt;p&gt;Do extraction before generation, not instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Budget the context
&lt;/h2&gt;

&lt;p&gt;Real pages do not fit neatly into a context window, so the reader either truncates to the highest-signal passages or marks them in place, best first, until the budget is spent. The answering model gets curated, ranked evidence instead of a pile of chunks competing for attention.&lt;/p&gt;

&lt;p&gt;The result is the difference between "here are some links" and an answer you can act on. Same retrieval, far better grounding, because something actually read the sources before the model spoke.&lt;/p&gt;

&lt;p&gt;That take is exactly why the reader step exists. Naive RAG earns its bad reputation. This is how you avoid it.&lt;/p&gt;

&lt;p&gt;(And don't get me started about not having to put it all in a vector database first!)&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>Making RAG admit when it's guessing: source-grounded hallucination checks</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Wed, 01 Jul 2026 15:12:48 +0000</pubDate>
      <link>https://dev.to/sidswirl/making-rag-admit-when-its-guessing-source-grounded-hallucination-checks-g22</link>
      <guid>https://dev.to/sidswirl/making-rag-admit-when-its-guessing-source-grounded-hallucination-checks-g22</guid>
      <description>&lt;p&gt;The failure mode that scares me most in RAG isn't a wrong answer. It's a confident wrong answer with three citations that don't actually say what the answer claims.&lt;/p&gt;

&lt;p&gt;So in SWIRL 5 I stopped trusting the model to police itself and added a check that runs &lt;em&gt;after&lt;/em&gt; generation.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate the answer with its citations, as usual.&lt;/li&gt;
&lt;li&gt;Split the answer into atomic claims — roughly one assertion per sentence.&lt;/li&gt;
&lt;li&gt;For each claim, pull the specific spans from the retrieved passages the model cited.&lt;/li&gt;
&lt;li&gt;Run an entailment check: does the cited text actually support this claim, contradict it, or neither?&lt;/li&gt;
&lt;li&gt;Any claim that isn't supported gets flagged in the UI, inline, before the user reads a word of it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The interesting part wasn't the entailment model; it was everything around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim segmentation is harder than it sounds.&lt;/strong&gt; Naive sentence splitting produces claims that are unverifiable on their own because the subject lives two sentences up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Citations lie by omission.&lt;/strong&gt; A model will cite a document that's &lt;em&gt;topically&lt;/em&gt; relevant but doesn't contain the specific number it just quoted. The whole point of the check is to catch exactly that gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency budget.&lt;/strong&gt; An honesty layer nobody waits for is an honesty layer nobody ships. SWIRL 5 batches and optionally caches passage embeddings and more. &lt;/p&gt;

&lt;p&gt;The result isn't "SWIRL never hallucinates." Nothing can promise that. The result is: when it's on thin ice, it tells you, and it points at the exact sentence.&lt;/p&gt;

&lt;p&gt;That's the version of trustworthy I can actually build.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
    </item>
    <item>
      <title>SWIRL Community 4.5 Update</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Sun, 28 Jun 2026 16:30:28 +0000</pubDate>
      <link>https://dev.to/sidswirl/swirl-community-45-update-3bji</link>
      <guid>https://dev.to/sidswirl/swirl-community-45-update-3bji</guid>
      <description>&lt;p&gt;I wrote about SWIRL here last summer. Time for an update.&lt;/p&gt;

&lt;p&gt;SWIRL Community 4.5 is out. It is the open-source, Apache-2.0 build of SWIRL: federated search and RAG across your apps, running on your own machine, no vector database required. Three things in this release are worth your attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Point RAG at any LLM
&lt;/h2&gt;

&lt;p&gt;The big one. Community now lets you &lt;a href="https://docs.swirlaiconnect.com/rag-guide#configuring-rag" rel="noopener noreferrer"&gt;configure AI Providers&lt;/a&gt; and send your RAG queries to any model you want: OpenAI, Anthropic, Azure OpenAI, or a fully local model behind Ollama or vLLM. Pick the provider, set the model, done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ai_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropic&lt;/span&gt;      &lt;span class="c1"&gt;# openai | azure | ollama | vllm&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-model&lt;/span&gt;
  &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${LLM_KEY}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters for two reasons. RAG quality is now your choice, not ours, so you can run a stronger model when accuracy counts. And you can keep the whole pipeline local, with retrieval and generation both inside your network. This was an Enterprise feature; it is in Community as of 4.5.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Ask RAG a question directly
&lt;/h2&gt;

&lt;p&gt;RAG now accepts a natural-language question as its input. Before, you ran a search and generated over the results; now you can hand SWIRL a plain question and it does retrieval and generation in one step. Question in, cited answer out, which means a lot less glue code to wire SWIRL into a chat box or an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Updated Galaxy
&lt;/h2&gt;

&lt;p&gt;Community now includes the new Galaxy 5 UI, including a search history widget + dashboard, cleaned up admin interface, built-in activity analytics and more. &lt;/p&gt;

&lt;p&gt;That is the release. Free, self-hosted, and now model-agnostic.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>nlp</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The knowledge-authority layer: what your agents can't get from the outside</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:28:55 +0000</pubDate>
      <link>https://dev.to/sidswirl/the-knowledge-authority-layer-what-your-agents-cant-get-from-the-outside-f4i</link>
      <guid>https://dev.to/sidswirl/the-knowledge-authority-layer-what-your-agents-cant-get-from-the-outside-f4i</guid>
      <description>&lt;p&gt;Every enterprise AI conversation right now starts in the same place: "connect the model to our data." Then it stalls in the same place: &lt;em&gt;which&lt;/em&gt; data, copied &lt;em&gt;where&lt;/em&gt;, governed by &lt;em&gt;whom&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Let me make an argument that runs against the current default - and then show the architecture it implies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default is a second copy of your data
&lt;/h2&gt;

&lt;p&gt;The standard RAG recipe is: crawl your sources, chunk them, embed them, and load the vectors into a database. Now your model can retrieve. It also means you have a &lt;em&gt;second copy&lt;/em&gt; of your content living in an index you have to secure, keep in sync, and explain to whoever owns compliance. You've recreated every permission boundary by hand, and you'll eventually get one wrong.&lt;/p&gt;

&lt;p&gt;For a lot of teams that copy is simply not allowed. Regulated content, client-confidential material, anything privileged - copying it into a vendor store is exposure you don't get paid to take on.&lt;/p&gt;

&lt;h2&gt;
  
  
  You probably don't need the vector database
&lt;/h2&gt;

&lt;p&gt;Here's the part people don't want to hear. Meta's XetHub team &lt;a href="https://web.archive.org/web/20251221204717/https://xethub.com/blog/you-dont-need-a-vector-database" rel="noopener noreferrer"&gt;benchmarked three retrieval strategies&lt;/a&gt;: keyword-only (BM25), vector-only, and hybrid (keyword to pull candidates, then re-rank). Keyword-only came last. Vector-only did better. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hybrid won&lt;/strong&gt; - and their conclusion was blunt: "No vector database necessary."&lt;/p&gt;

&lt;p&gt;That matches what we see in production. Vector similarity is a great &lt;em&gt;high-precision filter&lt;/em&gt;, not a great &lt;em&gt;first pass&lt;/em&gt;. Lead with exact matches and quoted terms, then let embeddings and a cross-encoder re-rank what's left.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "make your LLM better" actually means
&lt;/h2&gt;

&lt;p&gt;It's not a slogan; it's a pipeline. In SWIRL, relevance is three passes, and both models run locally:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Federate and match.&lt;/strong&gt; Query every connected source in parallel - keyword + BM25 - and honor quoted phrases and exact terms first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding re-rank.&lt;/strong&gt; Re-rank candidates with &lt;code&gt;E5-large-v2&lt;/code&gt;, using title-aware chunking and hybrid keyword+vector fusion (RRF). No vector database to build or secure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder re-rank.&lt;/strong&gt; An &lt;code&gt;MS-MARCO&lt;/code&gt; cross-encoder reads the query and document &lt;em&gt;together&lt;/em&gt; and scores real relevance, not vector distance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Feed &lt;em&gt;that&lt;/em&gt; to your LLM - whatever model you've chosen, including an on-prem one - and the answer gets better, because the context got better. Same model, sharper input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer no model supplies from the outside
&lt;/h2&gt;

&lt;p&gt;The stack is settling: foundation models orchestrate, MCP is the retrieval interface, the chat UI is a commodity. The piece none of them provide from outside your walls is &lt;strong&gt;knowledge authority&lt;/strong&gt; - which document is official, which clause your org actually uses, which answer carries approval.&lt;/p&gt;

&lt;p&gt;So we made it a first-class layer. SWIRL 5 exposes an MCP server. Any agent - Claude, Copilot, ChatGPT, your own - calls SWIRL and gets ranked, permissioned, &lt;em&gt;organization-approved&lt;/em&gt; answers. A team pins the canonical result for a query once; every agent gets it after that. And no copy of your data leaves your tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape
&lt;/h2&gt;

&lt;p&gt;Three properties fall out of it, and they're the whole reason to build it this way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private by architecture.&lt;/strong&gt; Data stays in place; permissions are enforced live; there's no second index to govern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The answer, not a guess.&lt;/strong&gt; Cross-encoder ranking plus canonical answers means people and agents get the result the org trusts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The safe on-ramp to AI.&lt;/strong&gt; Headless and MCP-native, deployed in your tenant - the lowest-risk way to give agents enterprise reach.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're wiring agents into enterprise data and the "just copy everything into a vector store" step is making your security team twitch, there's another shape available. SWIRL 5 goes GA July 15; the preview is open if you want to point it at your own stack. Either way - I'd genuinely like to hear how you're handling the authority problem, because I don't think the industry has it figured out yet.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sid Probstein is the creator of SWIRL and CEO of SWIRL AI.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Moving Docker images between repos with crane (after imagetools wasted my afternoon)</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Thu, 11 Jun 2026 17:55:15 +0000</pubDate>
      <link>https://dev.to/sidswirl/moving-docker-images-between-repos-with-crane-after-imagetools-wasted-my-afternoon-bjg</link>
      <guid>https://dev.to/sidswirl/moving-docker-images-between-repos-with-crane-after-imagetools-wasted-my-afternoon-bjg</guid>
      <description>&lt;p&gt;I had a freshly-built, multi-arch dev image (&lt;code&gt;linux/amd64&lt;/code&gt; + &lt;code&gt;linux/arm64&lt;/code&gt;) and one job: promote it into a private partner repo on Docker Hub, plus stamp a dated tag so &lt;code&gt;:latest&lt;/code&gt; is always traceable back to a real build. Cross-repo. Should be five minutes.&lt;/p&gt;

&lt;p&gt;It was not five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The obvious tool is &lt;code&gt;docker buildx imagetools create&lt;/code&gt;... it's built for copying manifests between tags. So I reached for it. And it sat there. Then it 400'd. Then I retried, and it hung. Cross-repo blob copies on Docker Hub are reproducibly flaky with imagetools, and I burned the better part of an hour confirming that before I went looking for something else.&lt;/p&gt;

&lt;p&gt;The fallback most people reach for next is worse: pull the image down, retag it, push it to the new repo. That round-trips the &lt;em&gt;entire&lt;/em&gt; image — every layer, every arch - through your laptop's daemon and disk, only to push the same bits back up. And if you're not careful with how you tag, you flatten a multi-arch index down to whatever single arch your machine happens to be. No thanks.&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.amazonaws.com%2Fuploads%2Farticles%2Fxnetefv8hsa8yrjqd3ix.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.amazonaws.com%2Fuploads%2Farticles%2Fxnetefv8hsa8yrjqd3ix.png" alt=" " width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why crane
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/google/go-containerregistry/tree/main/cmd/crane" rel="noopener noreferrer"&gt;&lt;code&gt;crane&lt;/code&gt;&lt;/a&gt; (from Google's go-containerregistry) does the copy &lt;strong&gt;registry-to-registry&lt;/strong&gt;. It never pulls the image to your machine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It preserves the multi-arch manifest.&lt;/strong&gt; &lt;code&gt;crane cp&lt;/code&gt; copies the whole image &lt;em&gt;index&lt;/em&gt; by digest. Both arches come along. Nothing gets flattened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It actually works cross-repo on Docker Hub.&lt;/strong&gt; Where imagetools 400'd and hung, crane did the server-to-server copy in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No daemon, no disk.&lt;/strong&gt; It talks to the registries directly. Your laptop just orchestrates.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    subgraph slow["pull → tag → push"]
        A[source repo&amp;lt;br/&amp;gt;you/app:dev] --&amp;gt; L[your daemon + disk&amp;lt;br/&amp;gt;whole image] --&amp;gt; B[partner repo&amp;lt;br/&amp;gt;you/partner-app:latest]
    end
    subgraph crane["crane cp"]
        C[source repo&amp;lt;br/&amp;gt;you/app:dev] --&amp;gt;|manifest + blobs&amp;lt;br/&amp;gt;by digest| D[partner repo&amp;lt;br/&amp;gt;you/partner-app:latest]
    end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Run it as a container, no install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; gcr.io/go-containerregistry/crane:debug &amp;lt;crane-args&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole installation story. Nothing on the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth issues (MacOS)
&lt;/h2&gt;

&lt;p&gt;crane in the container reads &lt;code&gt;~/.docker/config.json&lt;/code&gt;. On &lt;strong&gt;macOS with Docker Desktop&lt;/strong&gt;, your login isn't &lt;em&gt;in&lt;/em&gt; that file — it's &lt;code&gt;credsStore: osxkeychain&lt;/code&gt;, sitting in the macOS keychain. So if you naively mount &lt;code&gt;~/.docker/config.json&lt;/code&gt; into the container, crane sees no usable credential and hands you &lt;code&gt;UNAUTHORIZED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix: pull the credential out of the keychain on the host, write it into a &lt;em&gt;temporary inline&lt;/em&gt; config, mount that, and delete it the moment you're done. Never print it, never commit it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'rm -rf "$TMP"'&lt;/span&gt; EXIT
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/mkcfg.py"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import json, sys, base64
d = json.load(sys.stdin)
auth = base64.b64encode((d["Username"] + ":" + d["Secret"]).encode()).decode()
json.dump({"auths": {"https://index.docker.io/v1/": {"auth": auth}}}, open(sys.argv[1], "w"))
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"https://index.docker.io/v1/"&lt;/span&gt; | docker-credential-osxkeychain get | python3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/mkcfg.py"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/config.json"&lt;/span&gt;

CRANE&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/config.json"&lt;/span&gt;:/root/.docker/config.json:ro &lt;span class="se"&gt;\&lt;/span&gt;
            gcr.io/go-containerregistry/crane:debug &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;trap ... EXIT&lt;/code&gt; cleans up the temp config when your shell exits, so the credential doesn't linger.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;Linux&lt;/strong&gt; (or anywhere &lt;code&gt;docker login&lt;/code&gt; writes inline creds), skip all of that and mount &lt;code&gt;~/.docker/config.json&lt;/code&gt; directly. And obvious-but-worth-saying: you need a &lt;strong&gt;read/write&lt;/strong&gt; login on the destination. A read-only token can't push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promote the image
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# copy SRC -&amp;gt; DST, full multi-arch manifest, server to server:&lt;/span&gt;
CRANE &lt;span class="nb"&gt;cp &lt;/span&gt;you/app:dev &lt;span class="se"&gt;\&lt;/span&gt;
         you/partner-app:latest

&lt;span class="c"&gt;# also stamp a dated, immutable tag so :latest is always traceable to a build:&lt;/span&gt;
CRANE &lt;span class="nb"&gt;cp &lt;/span&gt;you/app:dev &lt;span class="se"&gt;\&lt;/span&gt;
         you/partner-app:dev-2026-06-11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two copies, both registry-side, both done before you can refill your coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;Don't trust that the copy worked. Prove it. The destination digest must equal the source digest — same bits, same manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;CRANE digest you/app:dev                 &lt;span class="c"&gt;# source&lt;/span&gt;
CRANE digest you/partner-app:latest      &lt;span class="c"&gt;# must equal source&lt;/span&gt;
CRANE digest you/partner-app:dev-2026-06-11   &lt;span class="c"&gt;# must equal source&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those three lines match, the right image landed under both tags. If they don't, you copied the wrong thing — better to find out here than in a partner's deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;CRANE &lt;span class="nb"&gt;ls &lt;/span&gt;you/partner-app                          &lt;span class="c"&gt;# list tags&lt;/span&gt;
CRANE manifest you/app:dev                         &lt;span class="c"&gt;# full manifest JSON, see the arches&lt;/span&gt;
CRANE tag you/app@sha256:&amp;lt;digest&amp;gt; newtag           &lt;span class="c"&gt;# add a tag to an existing digest&lt;/span&gt;
CRANE copy &amp;lt;SRC&amp;gt; &amp;lt;DST&amp;gt;                             &lt;span class="c"&gt;# alias of cp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat worth knowing: tag &lt;em&gt;deletion&lt;/em&gt; isn't a crane operation. Use the Docker Hub UI or the API for that (and it needs an admin-scoped token — a read/write PAT won't delete).&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Reach for crane any time you're moving an image &lt;em&gt;between registries or repos&lt;/em&gt; and you care about the manifest arriving intact — promotions, mirrors, cross-org handoffs. It skips the daemon, skips the disk, and it doesn't fall over on Docker Hub cross-repo copies the way imagetools does. And whatever you do, build the &lt;code&gt;crane digest&lt;/code&gt; source-equals-dest check into the workflow. It costs two seconds.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>resources</category>
    </item>
  </channel>
</rss>
