<?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>Build Your Own Private LLM: A 2026 Reference Architecture for the Enterprise</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Sat, 12 Sep 2026 13:32:50 +0000</pubDate>
      <link>https://dev.to/sidswirl/build-your-own-private-llm-a-2026-reference-architecture-for-the-enterprise-1l89</link>
      <guid>https://dev.to/sidswirl/build-your-own-private-llm-a-2026-reference-architecture-for-the-enterprise-1l89</guid>
      <description>&lt;p&gt;Every enterprise I talk to has the same conversation twice a year. Legal will not let the contract data leave the building. Security will not approve a vendor that trains on prompts. Finance has seen the API bill and would like a word. And somebody in the room says: &lt;em&gt;how hard would it be to just run our own?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Harder than a weekend. Easier than it was eighteen months ago. And the parts that are hard are not the parts people expect. The model is the easy bit. The hard bits are power, identity, and what happens when a document your model reads tells it to do something.&lt;/p&gt;

&lt;p&gt;This is an end-to-end reference architecture for a private LLM platform: the arithmetic to size it, the commands that actually run, and the numbers that decide whether it pays back. Everything is current as of &lt;strong&gt;September 2026&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There is a runnable companion too: &lt;a href="https://github.com/swirlai/private-llm-reference" rel="noopener noreferrer"&gt;swirlai/private-llm-reference&lt;/a&gt;, Apache-2.0. &lt;code&gt;docker compose up&lt;/code&gt; stands up an OAuth2 server, two MCP servers, an ACL-trimming backend and a gateway, and proves three security properties a local model server does not give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "private" actually means
&lt;/h2&gt;

&lt;p&gt;"Private LLM" gets used for four very different architectures, and the confusion costs people real money. Pick your posture before you pick your hardware.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Posture&lt;/th&gt;
&lt;th&gt;Weights&lt;/th&gt;
&lt;th&gt;Inference runs&lt;/th&gt;
&lt;th&gt;Prompts leave your control&lt;/th&gt;
&lt;th&gt;Typical driver&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vendor API with zero-retention terms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;vendor&lt;/td&gt;
&lt;td&gt;vendor&lt;/td&gt;
&lt;td&gt;yes, contractually bounded&lt;/td&gt;
&lt;td&gt;speed to value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Vendor model in your cloud tenancy&lt;/strong&gt; (Bedrock, Foundry, Vertex)&lt;/td&gt;
&lt;td&gt;vendor&lt;/td&gt;
&lt;td&gt;vendor-managed, your VPC&lt;/td&gt;
&lt;td&gt;no, but vendor code path&lt;/td&gt;
&lt;td&gt;procurement comfort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Open weights, your cloud&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yours&lt;/td&gt;
&lt;td&gt;your VMs, your VPC&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;control plus elasticity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Open weights, your metal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yours&lt;/td&gt;
&lt;td&gt;your data center&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;data residency, sovereignty, unit economics at scale&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two are what this article is about. They share almost the entire software stack and differ only in who owns the depreciation schedule and who gets paged when a power supply fails.&lt;/p&gt;

&lt;p&gt;Be honest about your driver. "We want control over our data" is real and on-prem solves it. "We want to save money" is real but only above a certain sustained utilization, and the math is in the cost section. "We want a frontier model that is also private" is a wish, not a driver.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: size the workload before you shop
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The most expensive mistake is buying hardware before you can state your workload in tokens.&lt;/strong&gt; Write these five numbers down first.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Peak concurrent requests.&lt;/strong&gt; Not users. Concurrent in-flight generations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Median and p95 input tokens.&lt;/strong&gt; Retrieval-augmented prompts are big. A RAG assistant with ten retrieved chunks runs 4,000 to 12,000 input tokens routinely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Median and p95 output tokens.&lt;/strong&gt; A chat reply is 300. A reasoning model's hidden chain of thought can be 20,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-to-first-token target.&lt;/strong&gt; Interactive chat wants under 500 ms. Batch summarization does not care.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inter-token latency target.&lt;/strong&gt; Human reading speed is roughly 5 to 8 tokens per second. Anything above 20 tokens/s per stream feels instant.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These map onto hardware differently. &lt;strong&gt;Prefill&lt;/strong&gt;, processing your input, is compute bound. &lt;strong&gt;Decode&lt;/strong&gt;, generating output, is memory-bandwidth bound. A cluster tuned for long-input summarization looks nothing like one tuned for short high-concurrency chat.&lt;/p&gt;

&lt;p&gt;The heuristic that matters: &lt;strong&gt;decode speed is governed by HBM bandwidth, not FLOPS.&lt;/strong&gt; Tokens per second is roughly memory bandwidth divided by bytes read per token. That is why an H200 often beats a nominally faster part with less bandwidth, and why quantization buys speed as well as capacity.&lt;/p&gt;

&lt;h3&gt;
  
  
  The memory arithmetic
&lt;/h3&gt;

&lt;p&gt;Weights first. Bytes per parameter: BF16/FP16 = 2, FP8 = 1, FP4 (NVFP4 or MXFP4) is about 0.5 plus scale overhead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model size&lt;/th&gt;
&lt;th&gt;BF16&lt;/th&gt;
&lt;th&gt;FP8&lt;/th&gt;
&lt;th&gt;FP4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8B&lt;/td&gt;
&lt;td&gt;16 GB&lt;/td&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;td&gt;~4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30B&lt;/td&gt;
&lt;td&gt;60 GB&lt;/td&gt;
&lt;td&gt;30 GB&lt;/td&gt;
&lt;td&gt;~15 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70B&lt;/td&gt;
&lt;td&gt;140 GB&lt;/td&gt;
&lt;td&gt;70 GB&lt;/td&gt;
&lt;td&gt;~35 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;120B&lt;/td&gt;
&lt;td&gt;234 GB&lt;/td&gt;
&lt;td&gt;117 GB&lt;/td&gt;
&lt;td&gt;~60 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;405B&lt;/td&gt;
&lt;td&gt;810 GB&lt;/td&gt;
&lt;td&gt;405 GB&lt;/td&gt;
&lt;td&gt;~203 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;675B&lt;/td&gt;
&lt;td&gt;1.35 TB&lt;/td&gt;
&lt;td&gt;675 GB&lt;/td&gt;
&lt;td&gt;~338 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The MoE trap.&lt;/strong&gt; Mixture-of-experts models advertise a small active parameter count. That governs &lt;em&gt;speed&lt;/em&gt;, not &lt;em&gt;memory&lt;/em&gt;. Every expert stays resident, so a 2.8T-total model needs 2.8T of VRAM whatever its active count. People plan around the active number and come up short by an order of magnitude.&lt;/p&gt;

&lt;p&gt;Now the KV cache, which is where capacity plans go to die:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;kv_bytes_per_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_layers&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_kv_heads&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bytes_per_element&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leading 2 is for K and V. For a 70B model with grouped-query attention (8 KV heads, 80 layers, 128 head dim) at BF16 that is about 0.33 MB per token. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;32K context: about 10.7 GB &lt;strong&gt;per concurrent request&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;128K context: about 43 GB &lt;strong&gt;per concurrent request&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sixteen concurrent users at 32K context is about 170 GB of KV cache, more than the weights themselves. This is why &lt;code&gt;--kv-cache-dtype fp8&lt;/code&gt; is close to a free win (halves it, near-lossless) and why models with multi-head latent attention (MLA) or aggressive GQA are disproportionately cheap to serve.&lt;/p&gt;

&lt;p&gt;A quick Python sizer worth keeping around:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;vram_estimate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;params_b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bytes_per_param&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;n_layers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;n_kv_heads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;head_dim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kv_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;context_len&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;overhead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Rough VRAM budget in GB. Overhead covers activations, CUDA graphs, fragmentation.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params_b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bytes_per_param&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt;
    &lt;span class="n"&gt;kv_per_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_layers&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_kv_heads&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kv_bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt;
    &lt;span class="n"&gt;kv_total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kv_per_token&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;context_len&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;concurrency&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weights_gb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kv_gb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_gb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;kv_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;overhead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# 70B at FP8, FP8 KV cache, 32K context, 32 concurrent
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;vram_estimate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32_768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# {'weights_gb': 70.0, 'kv_gb': 171.8, 'total_gb': 278.1}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output tells you something important: a 70B model at FP8 serving 32 concurrent users at 32K context needs roughly 278 GB. That is not one H200 and it is not two. It is four, or a shorter context window, or fewer concurrent slots. The weights were never the problem. Run this before you sign a purchase order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: getting GPUs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Buy two nodes of 8x RTX PRO 6000 Blackwell Server Edition to start, and do not wait for prices to fall.&lt;/strong&gt; The rest of this section is why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prices are going up
&lt;/h3&gt;

&lt;p&gt;The constraint moved from GPU dies to high-bandwidth memory, and new fab capacity does not land in volume until 2028. DRAM rose 93 to 98 percent quarter-over-quarter in Q1 2026. Reserved cloud capacity got &lt;em&gt;more&lt;/em&gt; expensive through H1 2026: the one-year H100 contract index went from about $1.70/GPU-hr in October 2025 to about $2.35 in March 2026, and CoreWeave raised list prices about 25 percent in July 2026. If you are waiting for a better entry point, you are waiting for a worse one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to buy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tier 0, the pilot.&lt;/strong&gt; One or two nodes of 8x &lt;strong&gt;RTX PRO 6000 Blackwell Server Edition&lt;/strong&gt;. 96 GB each, PCIe Gen5, configurable to 600 W, MIG-partitionable four ways, and &lt;strong&gt;air-coolable in a standard chassis&lt;/strong&gt; from Dell, HPE, Lenovo or Supermicro. About $16,000 per GPU, so roughly $130,000 of GPU per node. 768 GB per node serves 8B to 70B models, several at once.&lt;/p&gt;

&lt;p&gt;This is the highest-probability first purchase for a mid-size enterprise, and the reason is boring: it fits your existing rack, cooling and 8 to 15 kW power budget. No liquid retrofit, no 30-week lead time, no structural review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1, production.&lt;/strong&gt; Two to four nodes of 8x &lt;strong&gt;H200&lt;/strong&gt; at roughly $370,000 a node, or 8x &lt;strong&gt;B200&lt;/strong&gt; at roughly $450,000. Sixteen to thirty-two GPUs, $0.75M to $1.8M. Handles a 70B dense model at high concurrency or a 671B MoE at FP8 on one node. H200 for lowest risk and best dollars per GB of HBM; B200 if you need native FP4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not buy GB300 NVL72 first.&lt;/strong&gt; 135 kW, mandatory liquid, 1.36 tons, roughly $3.7M, six to twelve month lead time, and it does not fit through a standard data center door. Rack-scale NVLink wins for trillion-parameter dense inference. It is not what you want for a 70B assistant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;th&gt;TDP&lt;/th&gt;
&lt;th&gt;Cooling&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RTX PRO 6000 SE&lt;/td&gt;
&lt;td&gt;96 GB GDDR7&lt;/td&gt;
&lt;td&gt;up to 600 W&lt;/td&gt;
&lt;td&gt;air or liquid&lt;/td&gt;
&lt;td&gt;best pilot part&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H200 SXM&lt;/td&gt;
&lt;td&gt;141 GB HBM3e&lt;/td&gt;
&lt;td&gt;700 W&lt;/td&gt;
&lt;td&gt;air&lt;/td&gt;
&lt;td&gt;best risk-adjusted production buy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B200 SXM&lt;/td&gt;
&lt;td&gt;192 GB HBM3e&lt;/td&gt;
&lt;td&gt;1,000 W&lt;/td&gt;
&lt;td&gt;air/liquid&lt;/td&gt;
&lt;td&gt;FP4 native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B300 SXM&lt;/td&gt;
&lt;td&gt;288 GB HBM3e&lt;/td&gt;
&lt;td&gt;up to 1,400 W&lt;/td&gt;
&lt;td&gt;liquid&lt;/td&gt;
&lt;td&gt;14.5 kW/node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AMD MI350X&lt;/td&gt;
&lt;td&gt;288 GB HBM3e&lt;/td&gt;
&lt;td&gt;~1,000 W&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;air&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;most VRAM you can air-cool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AMD MI355X&lt;/td&gt;
&lt;td&gt;288 GB HBM3e&lt;/td&gt;
&lt;td&gt;1,400 W&lt;/td&gt;
&lt;td&gt;liquid&lt;/td&gt;
&lt;td&gt;same memory, denser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rubin R200&lt;/td&gt;
&lt;td&gt;288 GB HBM4&lt;/td&gt;
&lt;td&gt;~1.8 kW&lt;/td&gt;
&lt;td&gt;liquid&lt;/td&gt;
&lt;td&gt;partner availability H2 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Used Hopper is underrated. Refurbished H100 runs $21,000 to $34,000 per GPU, used 8-GPU servers $150,000 to $180,000. Dispersion is wide, so get quotes. Hopper is not collapsing in value: CoreWeave rebooked expiring 2022-vintage contracts at 95 percent of original pricing.&lt;/p&gt;

&lt;h3&gt;
  
  
  AMD, in one rule
&lt;/h3&gt;

&lt;p&gt;If you have a platform engineer who can read HIP and debug a container, AMD saves real money. MI300X is the cheapest HBM-class capacity available, and MI350X gives you 288 GB you can air cool, which no NVIDIA part does. If your team's first instinct on a CUDA error is to file a ticket, buy NVIDIA and spend the difference on people.&lt;/p&gt;

&lt;p&gt;ROCm 10.0 ships production vLLM and SGLang containers and is genuinely viable for mainstream models. But decode parity with NVIDIA is still a roadmap goal, new-model support lags by weeks to months, and Hugging Face's Text Embeddings Inference is NVIDIA-only, so an AMD-only RAG stack needs a different embedding server.&lt;/p&gt;

&lt;p&gt;Intel is not a credible primary platform for a 2026 decision. Among the alternatives, SambaNova and Tenstorrent are the only two that will genuinely sell you a box; Groq and Cerebras are cloud businesses now.&lt;/p&gt;

&lt;h3&gt;
  
  
  One 8-GPU node is a rack
&lt;/h3&gt;

&lt;p&gt;A DGX B300 draws 14.5 kW. Typical enterprise colo racks are provisioned at 8 to 15 kW. Your single node eats the entire cabinet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cooling&lt;/th&gt;
&lt;th&gt;Practical ceiling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Optimized air&lt;/td&gt;
&lt;td&gt;30 to 40 kW/rack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rear-door heat exchanger&lt;/td&gt;
&lt;td&gt;60 to 80 kW/rack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct-to-chip liquid&lt;/td&gt;
&lt;td&gt;60 to 120 kW/rack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immersion&lt;/td&gt;
&lt;td&gt;120+ kW/rack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Converting an air-cooled facility to liquid runs on the order of $2M to $3M per megawatt, with power upgrades often adding as much again. That is exactly why the Tier 0 pilot is a good first move: it is the largest useful deployment you can do without having the liquid conversation.&lt;/p&gt;

&lt;p&gt;Three more constraints that surprise people. Colo vacancy in North America hit a record-low 1 percent, and wholesale pricing is rising, about $196/kW/month in H2 2025. Enterprises are securing capacity 18 to 24 months ahead. Lead times run 8 to 16 weeks through an OEM and 30+ weeks direct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Networking and storage: do not overbuy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;You do not need InfiniBand for an inference cluster under 64 GPUs.&lt;/strong&gt; Inference is far less sensitive to collective latency than training.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fabric&lt;/th&gt;
&lt;th&gt;vs InfiniBand&lt;/th&gt;
&lt;th&gt;Amortized 3-yr&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;InfiniBand NDR/XDR&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;~$0.29/GPU/hr&lt;/td&gt;
&lt;td&gt;64+ GPUs, real training&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spectrum-X Ethernet&lt;/td&gt;
&lt;td&gt;85 to 90%&lt;/td&gt;
&lt;td&gt;~$0.19/GPU/hr&lt;/td&gt;
&lt;td&gt;mid-scale mixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned RoCEv2&lt;/td&gt;
&lt;td&gt;70 to 80%&lt;/td&gt;
&lt;td&gt;~$0.10/GPU/hr&lt;/td&gt;
&lt;td&gt;inference-first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you go RoCEv2, budget real engineering time for PFC and ECN tuning. Misconfigured, its latency becomes unpredictable and genuinely hard to debug.&lt;/p&gt;

&lt;p&gt;One hard rule: &lt;strong&gt;keep tensor parallelism inside a single NVLink domain.&lt;/strong&gt; Cross-node tensor parallelism is prohibitively slow. Go pipeline or data parallel across nodes instead.&lt;/p&gt;

&lt;p&gt;Storage is undemanding for inference. Baseline 1 GB/s per GPU. Weights, a vector index and logs fit comfortably on one good NVMe tier. Save the parallel filesystem conversation for fine-tuning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Buy versus rent: duty cycle decides
&lt;/h3&gt;

&lt;p&gt;Naively, a $25,000 GPU at $3.00/GPU-hr pays back in about 347 days of continuous use. Add power, cooling, space and staff and it is 18+ months at near-100 percent utilization.&lt;/p&gt;

&lt;p&gt;Here is the number that actually decides it. &lt;strong&gt;Cast AI measured average GPU utilization across enterprise Kubernetes clusters at roughly 5 percent.&lt;/strong&gt; At 5 percent duty cycle, a $3.44/GPU-hr committed rate costs $68.80 per genuinely useful hour. Owned or rented, low utilization is what kills you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So measure your duty cycle on rented capacity for a quarter before buying anything.&lt;/strong&gt; Below 40 to 60 percent sustained, on-prem will not pay back.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Neocloud on-demand&lt;/th&gt;
&lt;th&gt;Hyperscaler on-demand&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;B200&lt;/td&gt;
&lt;td&gt;$5.91 to $8.60/GPU-hr&lt;/td&gt;
&lt;td&gt;$14.00 to $16.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H200&lt;/td&gt;
&lt;td&gt;$3.99 to $6.31&lt;/td&gt;
&lt;td&gt;$7.91 to $10.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTX PRO 6000&lt;/td&gt;
&lt;td&gt;$1.41 to $2.50&lt;/td&gt;
&lt;td&gt;$3.36 to $5.50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hyperscalers run 2 to 3x neocloud for identical silicon. Committed pricing spreads wider still: 64x B200 for a year costs about $1.93M on CoreWeave and about $9.03M on Google Cloud. Check the discount structure, because it sets your break-even: CoreWeave Reserved at 60 percent off breaks even at 40 percent utilization, while AWS Capacity Blocks at 13 percent off need 86.8 percent. Watch the line items outside the headline rate too, since cluster networking adds roughly 40 percent and hyperscalers meter egress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: choosing and obtaining the model
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pick from the Apache-2.0 and MIT tier unless you have a reason not to, and read the actual LICENSE file at the commit you pin.&lt;/strong&gt; Open weights are not open source, and that distinction is where legal review stalls.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is deployable
&lt;/h3&gt;

&lt;p&gt;Open weights now trade within roughly one generation of frontier closed models, and on some coding and agentic work the gap has closed. What has not closed is operational: you own the evals, the safety tuning and the incident response.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Fits on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4-Pro&lt;/td&gt;
&lt;td&gt;1.6T / 49B active&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MIT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;multi-node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral Large 3&lt;/td&gt;
&lt;td&gt;675B / 41B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4 to 8 GPUs at FP4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inkling&lt;/td&gt;
&lt;td&gt;975B / 41B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;multi-node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4-Flash&lt;/td&gt;
&lt;td&gt;285B / 13B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MIT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4 to 8 GPUs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;td&gt;117B / 5.1B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 GPU, ships in MXFP4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral Small 4&lt;/td&gt;
&lt;td&gt;119B / 6B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 to 2 GPUs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Scout&lt;/td&gt;
&lt;td&gt;109B / 17B&lt;/td&gt;
&lt;td&gt;community&lt;/td&gt;
&lt;td&gt;1 to 2 GPUs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-27B&lt;/td&gt;
&lt;td&gt;27B dense&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 4.2&lt;/td&gt;
&lt;td&gt;3B / 8B / 30B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 GPU, IBM indemnifies via watsonx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;td&gt;to 31B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two planning notes that save real time. &lt;strong&gt;Llama 5 does not exist.&lt;/strong&gt; Llama 4 from April 2025 is the final Llama release; Meta moved to the closed Muse line. Content farms publish fabricated Llama 5 specifications, so do not put it on a roadmap. And &lt;strong&gt;most enterprises should live in the mid tier&lt;/strong&gt;, not the frontier tier. A 30B model right-sized to the task triples your throughput against a 70B and cuts unit cost proportionally.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three license tiers
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tier 1, unencumbered.&lt;/strong&gt; Apache 2.0 or MIT. No thresholds, no acceptable-use policy, no gates. DeepSeek, Mistral's open lineup, gpt-oss, Gemma 4, Qwen3.8-27B, Granite, Olmo, Apertus, Inkling. If your legal team has low tolerance for novel license review, restrict the candidate list to this tier and you are done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2, revenue or user gated.&lt;/strong&gt; These read like MIT for three paragraphs and then add numbered conditions. The pattern is consistent and, critically, &lt;strong&gt;most carve out purely internal use&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kimi K3:&lt;/strong&gt; MaaS providers over $20M revenue in any 12 months must negotiate separately. Attribution above 100M MAU or $20M monthly revenue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qwen3.8-Max:&lt;/strong&gt; attribution above 100M MAU or $20M monthly revenue; MaaS businesses above $50M trailing-twelve-month revenue need a paid license.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MiniMax:&lt;/strong&gt; mandatory attribution, and it binds fine-tunes and distilled derivatives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Llama 4:&lt;/strong&gt; the 700M MAU carve-out, branding requirements, an acceptable-use policy by reference, and EU restrictions on the multimodal variants. Not OSI open source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tier 3, territorial exclusions.&lt;/strong&gt; New, and the one to watch. MiniMax's H3 license excludes the &lt;strong&gt;United States, EU, UK and South Korea&lt;/strong&gt;, and the exclusion covers deploying the outputs, not just running the weights. Check for this clause explicitly.&lt;/p&gt;

&lt;p&gt;Pin every model to a commit SHA and archive the LICENSE that was in force at ingest. Hugging Face repos are mutable, and "it was MIT when we downloaded it" is only a defense if you can prove it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quantization policy
&lt;/h3&gt;

&lt;p&gt;Pick a house standard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;FP8 is the default.&lt;/strong&gt; Indistinguishable from BF16 for most work, half the memory, native on Hopper and Blackwell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NVFP4&lt;/strong&gt; when memory forces it on Blackwell. Within about 1 percent of FP8 on MMLU-Pro and GPQA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MXFP4&lt;/strong&gt; when you need portability. Runs on Blackwell and MI355X.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWQ or GPTQ&lt;/strong&gt; only on Ampere and Ada, where FP4 has no hardware path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The exception that matters: &lt;strong&gt;both FP4 formats degrade measurably on reasoning and math.&lt;/strong&gt; If your workload is reasoning-heavy, stay at FP8 and buy the memory. And benchmark on your own eval set, because published deltas are averages over benchmarks you do not run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting weights safely
&lt;/h3&gt;

&lt;p&gt;Four controls, in priority order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enforce safetensors at the registry layer.&lt;/strong&gt; Pickle formats execute arbitrary code at load. Make it a policy gate, not a convention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ban &lt;code&gt;trust_remote_code=True&lt;/code&gt; in production.&lt;/strong&gt; It executes arbitrary repo-supplied Python regardless of weight format, bypassing every other control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign at ingest with your own key.&lt;/strong&gt; No registry signs automatically. Signing at your gate proves the artifact passed your review, not that somebody uploaded it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hash-pin to a commit SHA, never a branch.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scanning is defense in depth, not a control: malware has been demonstrated evading pickle scanners via compression. Run ModelScan anyway, but do not rely on it.&lt;/p&gt;

&lt;p&gt;A minimal ingest gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Ingest gate: pull a pinned revision, enforce format, sign, record.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;huggingface_hub&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;snapshot_download&lt;/span&gt;

&lt;span class="n"&gt;ALLOWED_LICENSES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apache-2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openmdw-1.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;BANNED_SUFFIXES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.pth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.pkl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.ckpt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;snapshot_download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;repo_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;repo_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;revision&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# a commit SHA, never a branch name
&lt;/span&gt;            &lt;span class="n"&gt;local_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dest&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;repo_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;allow_patterns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.safetensors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LICENSE*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BANNED_SUFFIXES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;non-safetensors weight artifacts present: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto_map&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trust_remote_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model declares custom code; requires manual security review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;digests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;relative_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;as_posix&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.safetensors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repo_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;digests&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INGEST_MANIFEST.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# Sign the manifest with your internal key; refuse to load unsigned models at serve time.
&lt;/span&gt;    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cosign&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sign-blob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--yes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hashivault://model-signing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--output-signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INGEST_MANIFEST.sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INGEST_MANIFEST.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For restricted egress, run a proxy in a DMZ allowlisted to &lt;code&gt;huggingface.co&lt;/code&gt;, point clients at it with &lt;code&gt;HF_ENDPOINT&lt;/code&gt;, and set &lt;strong&gt;&lt;code&gt;HF_HUB_OFFLINE=1&lt;/code&gt;&lt;/strong&gt; in production so a cache miss fails loudly instead of quietly reaching the internet.&lt;/p&gt;

&lt;p&gt;For true air gap, weights run 10 to 400 GB. Move them on encrypted media or a one-way diode and treat every model update as a release event with security review and chain-of-custody logging.&lt;/p&gt;

&lt;p&gt;One governance note: NVIDIA announced a definitive agreement to acquire Hugging Face in early September 2026. Whatever your view, single-vendor dependency on the Hub is now a procurement question. Mirror accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: serving it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  vLLM is the default, and here is why
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;vLLM 0.29.0&lt;/strong&gt; (released 2026-09-09) is the pragmatic choice for enterprise serving: continuous batching, PagedAttention, prefix caching, FP8 KV cache, LoRA multiplexing, structured outputs, speculative decoding, disaggregated prefill/decode, an OpenAI-compatible API and a genuinely good Prometheus surface.&lt;/p&gt;

&lt;p&gt;Two things that will break your existing runbooks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The V0 engine is gone.&lt;/strong&gt; &lt;code&gt;VLLM_USE_V1&lt;/code&gt; no longer exists. There is nothing to enable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--guided-decoding-backend&lt;/code&gt; no longer exists.&lt;/strong&gt; Structured outputs moved to &lt;code&gt;--structured-outputs-config&lt;/code&gt;, taking a JSON object with &lt;code&gt;backend&lt;/code&gt; (one of &lt;code&gt;auto&lt;/code&gt;, &lt;code&gt;xgrammar&lt;/code&gt;, &lt;code&gt;guidance&lt;/code&gt;, &lt;code&gt;outlines&lt;/code&gt;, &lt;code&gt;lm-format-enforcer&lt;/code&gt;), plus &lt;code&gt;reasoning_parser&lt;/code&gt;, &lt;code&gt;enable_in_reasoning&lt;/code&gt; and related fields.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also, &lt;code&gt;python -m vllm.entrypoints.openai.api_server&lt;/code&gt; is deprecated in favour of &lt;code&gt;vllm serve&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A production launch command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve /models/mistral-small-4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--served-model-name&lt;/span&gt; corp-general-v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--port&lt;/span&gt; 8000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 32768 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.90 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-batched-tokens&lt;/span&gt; 8192 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-chunked-prefill&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-prefix-caching&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kv-cache-dtype&lt;/span&gt; fp8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quantization&lt;/span&gt; fp8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--async-scheduling&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-auto-tool-choice&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tool-call-parser&lt;/span&gt; hermes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--structured-outputs-config&lt;/span&gt; &lt;span class="s1"&gt;'{"backend":"xgrammar"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-lora&lt;/span&gt; &lt;span class="nt"&gt;--max-loras&lt;/span&gt; 4 &lt;span class="nt"&gt;--max-lora-rank&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--otlp-traces-endpoint&lt;/span&gt; http://otel-collector.observability:4317 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-prompt-tokens-details&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-per-request-metrics&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssl-certfile&lt;/span&gt; /certs/tls.crt &lt;span class="nt"&gt;--ssl-keyfile&lt;/span&gt; /certs/tls.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four of those matter most. &lt;code&gt;--enable-prefix-caching&lt;/code&gt; is close to free money for RAG and agents, where a long system prompt repeats on every call. &lt;code&gt;--enable-chunked-prefill&lt;/code&gt; stops a long input stalling every in-flight decode. &lt;code&gt;--max-num-seqs&lt;/code&gt; is your concurrency ceiling and bounds KV cache pressure, so it is the knob to turn when you see preemptions. &lt;code&gt;--tool-call-parser&lt;/code&gt; values are registry-dependent, so check your build rather than copying mine.&lt;/p&gt;

&lt;p&gt;Speculative decoding now has flat aliases, mutually exclusive with the config-object form:&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="c"&gt;# either&lt;/span&gt;
  &lt;span class="nt"&gt;--spec-method&lt;/span&gt; eagle3 &lt;span class="nt"&gt;--spec-model&lt;/span&gt; /models/draft-1b &lt;span class="nt"&gt;--spec-tokens&lt;/span&gt; 5
&lt;span class="c"&gt;# or&lt;/span&gt;
  &lt;span class="nt"&gt;--speculative-config&lt;/span&gt; &lt;span class="s1"&gt;'{"method":"eagle3","model":"/models/draft-1b","num_speculative_tokens":5}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Do not rely on vLLM's &lt;code&gt;--api-key&lt;/code&gt; for authentication.&lt;/strong&gt; vLLM's own documentation flags API-key auth as limited. It is a speed bump for accidental access, not a control. The gateway in the next section is your authentication boundary; vLLM should be reachable only from it, over mTLS, on a network policy that denies everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other engines, and when to use them
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SGLang&lt;/strong&gt;: strong prefix reuse and constrained generation, best for complex multi-turn and agentic control flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TensorRT-LLM&lt;/strong&gt;: peak throughput on NVIDIA-only fleets, at the cost of engine-build complexity. Worth it for one high-volume model, not a fleet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp&lt;/strong&gt;: CPU, edge, Apple Silicon, single user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama&lt;/strong&gt;: laptops and demos. Not a serving tier. I have seen it in production and it does not end well under concurrency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubernetes: the parts that matter
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;GPU Operator 26.7.0&lt;/strong&gt; brings the DRA driver to GA, requiring Kubernetes 1.34.2 or later. It deploys via a new &lt;code&gt;GPUCluster&lt;/code&gt; resource that &lt;strong&gt;cannot coexist with &lt;code&gt;ClusterPolicy&lt;/code&gt;&lt;/strong&gt;, so this is a migration decision, not a flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MIG gives hard isolation&lt;/strong&gt; and is what you want for multi-tenant SLOs. &lt;strong&gt;Time-slicing gives none&lt;/strong&gt; and is fine for dev. Neither substitutes for right-sizing a replica.&lt;/p&gt;

&lt;p&gt;A serving deployment, trimmed to the interesting parts:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;vllm-corp-general&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;vllm-corp-general&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;vllm-corp-general&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;prometheus.io/scrape&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
        &lt;span class="na"&gt;prometheus.io/port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000"&lt;/span&gt;
        &lt;span class="na"&gt;prometheus.io/path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/metrics"&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-runner&lt;/span&gt;          &lt;span class="c1"&gt;# bound to a SPIFFE identity&lt;/span&gt;
      &lt;span class="na"&gt;automountServiceAccountToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;RuntimeDefault&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&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;vllm&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.internal/vllm-openai:v0.29.0&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--model=/models/mistral-small-4"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--served-model-name=corp-general-v1"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--tensor-parallel-size=4"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-model-len=32768"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--gpu-memory-utilization=0.90"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-num-seqs=256"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--enable-chunked-prefill"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--enable-prefix-caching"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--kv-cache-dtype=fp8"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--async-scheduling"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--otlp-traces-endpoint=http://otel-collector.observability:4317"&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&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;HF_HUB_OFFLINE&lt;/span&gt;            &lt;span class="c1"&gt;# fail loudly, never silently fetch&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&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;VLLM_NO_USAGE_STATS&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
            &lt;span class="na"&gt;readOnlyRootFilesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/models&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;shm&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/dev/shm&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;startupProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                        &lt;span class="c1"&gt;# weight loading takes minutes&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8000&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/health&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8000&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&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;models&lt;/span&gt;
          &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;model-weights&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt;&lt;span class="pi"&gt;}&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;shm&lt;/span&gt;
          &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;medium&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Memory&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;sizeLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;16Gi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details people get wrong. The &lt;code&gt;/dev/shm&lt;/code&gt; volume is required for tensor parallelism; the default 64 MB will hang you. And the startup probe needs a long failure threshold, because loading 70 GB of weights off network storage is not fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autoscaling: scale on queue depth, not GPU utilization.&lt;/strong&gt; GPU utilization is a terrible signal for LLM serving because a fully-batched engine sits at 100 percent whether it is keeping up or drowning. The canonical signal is vLLM's own &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt;:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keda.sh/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScaledObject&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;vllm-corp-general&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scaleTargetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;vllm-corp-general&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;minReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;
  &lt;span class="na"&gt;pollingInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
  &lt;span class="na"&gt;cooldownPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;          &lt;span class="c1"&gt;# weight loading is expensive; do not thrash&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;serverAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus.observability:9090&lt;/span&gt;
        &lt;span class="c1"&gt;# KEDA's Prometheus scaler treats threshold as a per-replica average,&lt;/span&gt;
        &lt;span class="c1"&gt;# so this targets ~3 queued requests per replica.&lt;/span&gt;
        &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum(vllm:num_requests_waiting{model_name="corp-general-v1"})&lt;/span&gt;
        &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;vLLM exposes roughly forty Prometheus metrics. The ones worth dashboards on day one: &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt; (autoscaling), &lt;code&gt;vllm:kv_cache_usage_perc&lt;/code&gt; (capacity headroom), &lt;code&gt;vllm:num_preemptions&lt;/code&gt; (you are oversubscribed), &lt;code&gt;vllm:time_to_first_token_seconds&lt;/code&gt; and &lt;code&gt;vllm:inter_token_latency_seconds&lt;/code&gt; (your user-facing SLOs), and the prefix-cache hit ratio (your caching ROI).&lt;/p&gt;

&lt;p&gt;When you outgrow single-node replicas, &lt;strong&gt;llm-d&lt;/strong&gt; and &lt;strong&gt;NVIDIA Dynamo&lt;/strong&gt; give you disaggregated prefill/decode with KV-cache-aware routing, both on the Gateway API Inference Extension.&lt;/p&gt;

&lt;p&gt;Do not start here. Single-node replicas behind a gateway carry you a long way, and disaggregated serving adds a lot of operational surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: wrapping it with APIs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The gateway is the product, not the model server.&lt;/strong&gt; It is what your developers integrate with, what your security team audits, and what lets you swap the model underneath without a migration project.&lt;/p&gt;

&lt;p&gt;vLLM speaks the OpenAI protocol, so it is tempting to hand teams that endpoint and call it done. A raw inference endpoint has no notion of who is calling, no budget, no audit trail, no failover, and no way to deprecate a model without breaking every consumer.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the gateway tier must do
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate the human or workload.&lt;/strong&gt; OIDC for people, workload identity for services. Never a shared API key in a config file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorize per model.&lt;/strong&gt; Not everyone gets the 675B model or the internet-connected tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meter and cap.&lt;/strong&gt; Per-user, per-team, per-application budgets with hard stops. Unbounded consumption is now OWASP LLM06:2026 and it is a real availability risk, not a billing annoyance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route and fail over.&lt;/strong&gt; Model aliases (&lt;code&gt;corp-general&lt;/code&gt; rather than &lt;code&gt;mistral-small-4-fp8-tp4&lt;/code&gt;) so you can move the underlying model without touching consumers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redact and inspect.&lt;/strong&gt; PII detection inbound, guardrail classification both ways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit a complete audit record.&lt;/strong&gt; Who, what model, what prompt hash, what tools, what cost, what trace ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache.&lt;/strong&gt; Exact-match and semantic caching, with a scope key that includes identity so you never serve one user's answer to another.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The options
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Self-hostable&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LiteLLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.100.1 (2026-09-10)&lt;/td&gt;
&lt;td&gt;MIT, fully&lt;/td&gt;
&lt;td&gt;pragmatic default; virtual keys, budgets, 100+ providers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Agent Router&lt;/strong&gt; (formerly Envoy AI Gateway)&lt;/td&gt;
&lt;td&gt;1.1.0 (2026-08-21)&lt;/td&gt;
&lt;td&gt;yes, on Envoy Gateway&lt;/td&gt;
&lt;td&gt;includes a full MCP gateway with OAuth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kong AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.14 (2026-04-14)&lt;/td&gt;
&lt;td&gt;OSS core, most AI plugins enterprise&lt;/td&gt;
&lt;td&gt;shipped RFC 8693 token exchange&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apache APISIX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;current&lt;/td&gt;
&lt;td&gt;Apache-2.0, yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ai-proxy&lt;/code&gt;, &lt;code&gt;ai-rate-limiting&lt;/code&gt;, MCP plugins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no, SaaS only&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;disqualified for a private deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the rename: &lt;strong&gt;Envoy AI Gateway is now "Agent Router"&lt;/strong&gt; under the Agentic AI Foundation. Same code, same maintainers, new domain. Any config or doc referencing the old name is stale.&lt;/p&gt;

&lt;p&gt;Start with &lt;strong&gt;LiteLLM&lt;/strong&gt; for the control plane and add &lt;strong&gt;Kong or Agent Router&lt;/strong&gt; at the edge when you need token exchange and MCP brokering. A minimal config:&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;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;corp-general&lt;/span&gt;              &lt;span class="c1"&gt;# the alias your developers code against&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&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;hosted_vllm/corp-general-v1&lt;/span&gt;
      &lt;span class="na"&gt;api_base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://vllm-corp-general.inference.svc:8000/v1&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;os.environ/VLLM_INTERNAL_KEY&lt;/span&gt;
    &lt;span class="na"&gt;model_info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_input_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32768&lt;/span&gt;
      &lt;span class="na"&gt;supports_function_calling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;corp-general&lt;/span&gt;              &lt;span class="c1"&gt;# second replica pool, same alias&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&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;hosted_vllm/corp-general-v1&lt;/span&gt;
      &lt;span class="na"&gt;api_base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://vllm-corp-general-b.inference.svc:8000/v1&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;os.environ/VLLM_INTERNAL_KEY&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;corp-reasoning&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&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;hosted_vllm/deepseek-v4-flash&lt;/span&gt;
      &lt;span class="na"&gt;api_base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://vllm-reasoning.inference.svc:8000/v1&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;os.environ/VLLM_INTERNAL_KEY&lt;/span&gt;

&lt;span class="na"&gt;router_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;routing_strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;least-busy&lt;/span&gt;
  &lt;span class="na"&gt;num_retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;allowed_fails&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;cooldown_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;fallbacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;corp-reasoning&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corp-general"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;drop_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;set_verbose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;callbacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otel"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langfuse"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;redact_user_api_key_info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;cache_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/REDIS_HOST&lt;/span&gt;
    &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt;
    &lt;span class="c1"&gt;# scope cache entries by caller so answers never cross a tenant boundary&lt;/span&gt;
    &lt;span class="na"&gt;supported_call_types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acompletion"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;atext_completion"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;general_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;master_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/LITELLM_MASTER_KEY&lt;/span&gt;
  &lt;span class="na"&gt;database_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/DATABASE_URL&lt;/span&gt;
  &lt;span class="na"&gt;enforce_user_param&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                &lt;span class="c1"&gt;# every request must carry an end-user id&lt;/span&gt;
  &lt;span class="na"&gt;max_budget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25000&lt;/span&gt;                       &lt;span class="c1"&gt;# platform-wide monthly ceiling, USD&lt;/span&gt;
  &lt;span class="na"&gt;budget_duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;
  &lt;span class="na"&gt;alerting&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slack"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then issue scoped virtual keys per team rather than distributing the master key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://llm-gateway.internal/key/generate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LITELLM_MASTER_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
        "team_id": "contracts-team",
        "models": ["corp-general"],
        "max_budget": 500,
        "budget_duration": "30d",
        "rpm_limit": 120,
        "tpm_limit": 400000,
        "metadata": {"owner": "legal-ops", "data_class": "confidential"}
      }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Identity: the part that is usually wrong
&lt;/h3&gt;

&lt;p&gt;Here is the failure mode I see most often. A user authenticates to a chat UI. The UI holds a service account key for the gateway. The gateway holds a service account key for the tool backends. By the time a tool executes a query, the identity of the human who asked has evaporated, and every user has the union of every permission the service account holds.&lt;/p&gt;

&lt;p&gt;That is a confused deputy, and in an agentic system it is how a prompt-injected document reads someone else's payroll data.&lt;/p&gt;

&lt;p&gt;The correct pattern has three layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1, workload identity.&lt;/strong&gt; SPIFFE/SPIRE issues an identity to every pod, and mTLS authenticates by that identity rather than by network position. This answers "which service is calling." It does &lt;strong&gt;not&lt;/strong&gt; carry the human.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2, user identity propagation.&lt;/strong&gt; The user's OIDC token must not be forwarded verbatim downstream. The gateway &lt;strong&gt;exchanges&lt;/strong&gt; it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User authenticates to the gateway via OIDC. The gateway now holds a token audienced to the gateway.&lt;/li&gt;
&lt;li&gt;Gateway performs an &lt;strong&gt;RFC 8693 token exchange&lt;/strong&gt; at the authorization server: presents the user token as &lt;code&gt;subject_token&lt;/code&gt;, sets &lt;code&gt;resource&lt;/code&gt; (RFC 8707) to the canonical URI of the specific downstream service, and narrows &lt;code&gt;scope&lt;/code&gt; to the minimum this call needs.&lt;/li&gt;
&lt;li&gt;The AS returns a token whose &lt;code&gt;aud&lt;/code&gt; is that one service, carrying the user as &lt;code&gt;sub&lt;/code&gt; and the gateway as &lt;code&gt;act&lt;/code&gt; (the actor claim, which is the on-behalf-of signal).&lt;/li&gt;
&lt;li&gt;Gateway calls the service with that token, over mTLS.&lt;/li&gt;
&lt;li&gt;The service validates signature, audience against its own resource identifier, scope, and expiry, and rejects anything not minted for it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Kong AI Gateway 3.14 productized step 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3, secrets.&lt;/strong&gt; Vault, now on 2.x, for provider keys and database credentials, with SPIFFE-authenticated login so no bootstrap secret sits on disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit.&lt;/strong&gt; Propagate W3C trace context end to end and record &lt;code&gt;sub&lt;/code&gt; plus &lt;code&gt;act&lt;/code&gt; at every hop, so a tool invocation is attributable to a human rather than to a service account. This is the artifact your auditors will actually ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: security
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;There is no reliable prompt-level defense against prompt injection. Stop looking for one and put the controls where the document cannot reach.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The threat model
&lt;/h3&gt;

&lt;p&gt;Two OWASP lists govern this now. The &lt;strong&gt;GenAI LLM Top 10, 2026 edition&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Movement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM01&lt;/td&gt;
&lt;td&gt;Prompt Injection&lt;/td&gt;
&lt;td&gt;unchanged at #1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM02&lt;/td&gt;
&lt;td&gt;Sensitive Information Disclosure&lt;/td&gt;
&lt;td&gt;unchanged at #2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM03&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Excessive Agency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;up from #6, biggest mover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM04&lt;/td&gt;
&lt;td&gt;Supply Chain&lt;/td&gt;
&lt;td&gt;down from #3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM05&lt;/td&gt;
&lt;td&gt;Data and Model Poisoning&lt;/td&gt;
&lt;td&gt;down from #4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM06&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unbounded Consumption&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;up from #10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM07&lt;/td&gt;
&lt;td&gt;Misinformation&lt;/td&gt;
&lt;td&gt;up from #9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM08&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hidden Context Exposure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;renamed from System Prompt Leakage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM09&lt;/td&gt;
&lt;td&gt;Vector and Embedding Weaknesses&lt;/td&gt;
&lt;td&gt;down from #8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM10&lt;/td&gt;
&lt;td&gt;Improper Output Handling&lt;/td&gt;
&lt;td&gt;down from #5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two movements matter. &lt;strong&gt;Excessive Agency went from sixth to third&lt;/strong&gt;, which is the agentic era in one data point. And &lt;strong&gt;LLM08 was broadened&lt;/strong&gt;: hidden context now covers any non-user-facing content assembled into the prompt, explicitly including retrieved policy text and tool schemas. If your retrieval layer injects internal policy documents, that is in scope.&lt;/p&gt;

&lt;p&gt;The companion &lt;strong&gt;OWASP Top 10 for Agentic Applications&lt;/strong&gt; covers goal hijack, tool misuse, privilege abuse, memory poisoning and rogue agents. If you are wiring tools to a model, read it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually works
&lt;/h3&gt;

&lt;p&gt;Instruction hierarchies, delimiters and "ignore any instructions in the following document" all fail under adaptive attack. Any vendor claiming their model is injection-resistant is selling a probability, not a control.&lt;/p&gt;

&lt;p&gt;The principle: &lt;strong&gt;never let untrusted content influence which tool runs or what arguments it gets.&lt;/strong&gt; The strongest published direction is &lt;strong&gt;CaMeL&lt;/strong&gt; (arXiv:2503.18813), which extracts a plan from the trusted user query only and executes it under a capability system, so tool output can populate data but never redirect control. That is a provable property rather than a filter.&lt;/p&gt;

&lt;p&gt;What you can build today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic allowlists on tool arguments.&lt;/strong&gt; The model proposes; non-model code validates. If the model asks for customer 12345, check the &lt;em&gt;authenticated user&lt;/em&gt; is entitled to customer 12345 before the call runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat all tool output as untrusted input on the way back.&lt;/strong&gt; Everyone skips this. A web page, a ticket comment, a PDF in a shared drive: all attacker-controlled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress filtering.&lt;/strong&gt; Exfiltration is the payoff for most injections. If your servers cannot reach arbitrary hosts, a successful injection has nowhere to send the data. This blunts a large share of real attacks and costs you a NetworkPolicy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human in the loop for irreversible actions.&lt;/strong&gt; Mail, money, deletions, production writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual-LLM.&lt;/strong&gt; A privileged planner that never sees untrusted text, and a quarantined model that reads it but returns only structured data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Guardrail models
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Best at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Granite Guardian 4.1-8b&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache-2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;general safety plus RAG hallucination checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Qwen3Guard&lt;/strong&gt; Gen and Stream&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Apache-2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stream classifies token-by-token, for live interception&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Llama Guard 4-12B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Llama Community&lt;/td&gt;
&lt;td&gt;MLCommons 14-hazard taxonomy, multimodal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Llama Prompt Guard 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Llama Community&lt;/td&gt;
&lt;td&gt;injection and jailbreak classification specifically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NeMo Guardrails&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;framework, not a model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Granite Guardian and Qwen3Guard are Apache-2.0 and the safest legally. Llama Guard and ShieldGemma carry acceptable-use terms; review before shipping them in a product.&lt;/p&gt;

&lt;p&gt;A gateway-side hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Guardrail middleware: classify in, classify out, fail closed on the way in.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="n"&gt;GUARD_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://vllm-guard.inference.svc:8000/v1/chat/completions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;BLOCK_ON_INPUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jailbreak&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pii_exfiltration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;violent_crimes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code_interpreter_abuse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;BLOCK_ON_OUTPUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pii_leak&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential_leak&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;violent_crimes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;GUARD_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;granite-guardian-4.1-8b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;safe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guarded_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;inbound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;inbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;BLOCK_ON_INPUT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked_input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request blocked: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;inbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;outbound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;BLOCK_ON_OUTPUT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked_output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I can&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t share that. Withheld by policy.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three notes. Run the guard on a &lt;strong&gt;separate small replica&lt;/strong&gt; so a guard timeout cannot take down your serving pool. Decide explicitly whether a timeout fails open or closed: inbound closed, outbound streaming usually open with async flagging, because blocking mid-token is a terrible experience. And log every verdict with the trace ID, because the false-positive rate decides whether people route around your platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  The data boundary
&lt;/h3&gt;

&lt;p&gt;You built this to keep data in. Then somebody enables verbose request logging and every prompt lands in a log aggregator half the company can query.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log &lt;strong&gt;prompt hashes and token counts&lt;/strong&gt; by default, not prompt text. Make full capture an explicit, time-boxed, separately authorized debugging mode.&lt;/li&gt;
&lt;li&gt;Set trace retention independently from metrics. Thirty days is usually plenty and shrinks exposure sharply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope caches by identity.&lt;/strong&gt; A semantic cache keyed only on prompt similarity will serve one user's answer to another.&lt;/li&gt;
&lt;li&gt;Give the vector index &lt;strong&gt;the same data classification as its source documents&lt;/strong&gt;. Embeddings are not anonymized; inversion attacks recover meaningful text.&lt;/li&gt;
&lt;li&gt;Decide up front whether you retain prompts for fine-tuning. If yes, that is a separate consent and retention regime, and it belongs in your privacy notice before you collect anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: MCP, or how the model reaches your systems
&lt;/h2&gt;

&lt;p&gt;A private LLM that cannot see your data is expensive autocomplete. MCP is how you connect it. &lt;strong&gt;If your mental model of MCP is from 2025, four things will break your code.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed on 2026-07-28
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sessions are gone&lt;/strong&gt;, including &lt;code&gt;Mcp-Session-Id&lt;/code&gt;. Servers are stateless and scale horizontally without sticky sessions, which is a real operational win. Cross-call state now uses server-minted handles passed as ordinary tool arguments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;initialize&lt;/code&gt; handshake is gone.&lt;/strong&gt; Every request carries its protocol version in &lt;code&gt;_meta&lt;/code&gt;, and servers must implement a new &lt;code&gt;server/discover&lt;/code&gt; RPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-initiated requests are gone.&lt;/strong&gt; Instead the server returns an &lt;code&gt;InputRequiredResult&lt;/code&gt; and the client retries with &lt;code&gt;inputResponses&lt;/code&gt;. Every result now carries a &lt;code&gt;resultType&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSE resumability was removed.&lt;/strong&gt; A broken stream loses the in-flight request, so make your tools idempotent or safely retryable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also deprecated: Roots, Sampling, Logging, the HTTP+SSE transport, and OAuth Dynamic Client Registration. Streamable HTTP is the transport; stdio stays for local tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Python SDK went to v2
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FastMCP&lt;/code&gt; no longer exists.&lt;/strong&gt; The module is a tombstone that raises on import. The class is &lt;code&gt;mcp.server.MCPServer&lt;/code&gt;. Pin &lt;code&gt;mcp&amp;gt;=1.28,&amp;lt;2&lt;/code&gt; if you are not ready. The TypeScript SDK is still on 1.x, so do not assume parity.&lt;/p&gt;

&lt;p&gt;Here is the security-critical core of a server. The &lt;a href="https://github.com/swirlai/private-llm-reference" rel="noopener noreferrer"&gt;full working version is in the companion repo&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MCPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AuthSettings&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.middleware.auth_context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_access_token&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corp-contracts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token_verifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;IntrospectingVerifier&lt;/span&gt;&lt;span class="p"&gt;(...),&lt;/span&gt;     &lt;span class="c1"&gt;# sets AccessToken.resource from the token aud
&lt;/span&gt;    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AuthSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;issuer_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ISSUER_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;resource_server_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RESOURCE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# this server's RFC 8707 identifier
&lt;/span&gt;        &lt;span class="n"&gt;required_scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contracts.read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;validate_token_resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;# refuse tokens minted for anything else
&lt;/span&gt;        &lt;span class="n"&gt;identity_assertion_enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;# SEP-990 enterprise IdP flow
&lt;/span&gt;    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnlyHint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_contracts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SearchResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_access_token&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no authenticated principal on this request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Query the backend AS THE USER. This server holds no superuser credential,
&lt;/span&gt;    &lt;span class="c1"&gt;# so a prompt injection that reaches this tool has nothing to steal.
&lt;/span&gt;    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CA&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;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BACKEND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-On-Behalf-Of&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SearchResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ContractHit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
                        &lt;span class="n"&gt;total_matched&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                        &lt;span class="n"&gt;truncated_by_permission&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denied_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things carry the security here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;validate_token_resource=True&lt;/code&gt; is the control.&lt;/strong&gt; Without it, a token minted for the ticketing server is happily accepted by the contracts server. In SDK 2.x, leaving it unset warns and behaves as &lt;code&gt;False&lt;/code&gt;, so set it explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tool calls the backend with the user's token.&lt;/strong&gt; If this server held a superuser key, an injection reaching this tool would have that key's full authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;truncated_by_permission&lt;/code&gt; returns a count, never content.&lt;/strong&gt; Telling the model "4 more exist that you cannot see" is useful. Returning them is a breach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ToolAnnotations&lt;/code&gt; are advisory.&lt;/strong&gt; &lt;code&gt;readOnlyHint&lt;/code&gt; lets a client auto-approve. It does not stop your code writing. Enforce in the handler.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  MCP security, concretely
&lt;/h3&gt;

&lt;p&gt;The spec's hard rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is worth watching rather than reading. Below, a token minted for the contracts server is replayed against the tickets server and refused with a 401 and a real &lt;code&gt;WWW-Authenticate&lt;/code&gt; challenge. Then &lt;code&gt;validate_token_resource&lt;/code&gt; is turned off and the identical replay succeeds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9r62c86ayuyzdfkglww7.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9r62c86ayuyzdfkglww7.gif" alt="A token minted for the contracts MCP server is refused by the tickets server with HTTP 401, then accepted once the audience check is disabled" width="600" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The full stack that produced that clip is public.&lt;/strong&gt; Five services, two MCP servers, the policy layer and the property tests: &lt;strong&gt;&lt;a href="https://github.com/swirlai/private-llm-reference" rel="noopener noreferrer"&gt;github.com/swirlai/private-llm-reference&lt;/a&gt;&lt;/strong&gt;. &lt;code&gt;docker compose up&lt;/code&gt;, then &lt;code&gt;make demo&lt;/code&gt;, and all three security properties run on your machine in about a minute.&lt;/p&gt;

&lt;p&gt;No model runs in that recording. A prompt cannot stop a token being replayed. A one-line audienceGive m check can, and it lives in the resource server, not the model and not the client.&lt;/p&gt;

&lt;p&gt;The operational controls beyond the spec:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin and hash tool definitions.&lt;/strong&gt; A rug pull is a server changing a tool's description after approval. Alert on drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render full tool descriptions to whoever approves them.&lt;/strong&gt; Tool poisoning hides instructions in description text the approver never sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never auto-trust one server's output as instructions.&lt;/strong&gt; Tool results are data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate credentials per server&lt;/strong&gt;, so a compromised server cannot borrow another's authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run an internal MCP registry.&lt;/strong&gt; The official one is in preview, does not support private servers, and is not designed for self-hosting. Run your own implementing its OpenAPI interface, and make it the only place agents discover tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For enterprise identity, &lt;strong&gt;SEP-990 (the Identity Assertion JWT Authorization Grant)&lt;/strong&gt; is the flow you want. It carries a signed assertion from your IdP and exchanges it for an MCP access token. Its trust model is inverted from ordinary MCP OAuth: the authorization server is configuration, not discovery. No metadata fetch, no dynamic registration, no server-driven scope selection, which eliminates the mix-up and confused-deputy classes at the root rather than defending against them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: connecting it to your data
&lt;/h2&gt;

&lt;p&gt;Most of the value in a private LLM comes from retrieval, not generation. Two things determine whether this works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permission-trimmed retrieval.&lt;/strong&gt; The vector index must not be a permission bypass. Either filter at query time on the caller's groups, which is faster but needs your ACLs denormalized into the index, or retrieve candidates and re-check entitlement against the source system, which is slower and always correct. Either way, the check happens &lt;strong&gt;before text enters the context window&lt;/strong&gt;. Once a document is in the prompt, it is disclosed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings and reranking.&lt;/strong&gt; Current sensible defaults, all self-hostable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Embedding, general&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Qwen3-Embedding-0.6B / 4B / 8B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;tops MTEB multilingual; Matryoshka dims 32 to 4096; 32K context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding, hybrid&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;BGE-M3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;dense + sparse + multi-vector in one pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding, long docs&lt;/td&gt;
&lt;td&gt;Jina Embeddings v4&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;128K context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reranking, default&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;BGE-Reranker-v2-m3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;568M, ~35 ms/pair&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reranking, quality&lt;/td&gt;
&lt;td&gt;Qwen3-Reranker-8B&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;~77% BEIR nDCG@10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reranking, latency&lt;/td&gt;
&lt;td&gt;ms-marco-MiniLM-L-12-v2&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;33M, under 5 ms/pair&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Avoid &lt;strong&gt;NV-Embed-v2&lt;/strong&gt; and &lt;strong&gt;Jina ColBERT v2&lt;/strong&gt; in commercial deployments: both are CC-BY-NC. And verify every license on the model card rather than in a comparison article, because this specific category is the most consistently misreported.&lt;/p&gt;

&lt;p&gt;Two notes. Embedding models turn over far slower than LLMs, so standardize on one and leave it alone, because changing it means reindexing everything. And &lt;strong&gt;a reranker is the highest return per dollar in a RAG stack.&lt;/strong&gt; Adding one usually beats upgrading your generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: observability, evals, and the acceptance gate
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Instrumentation
&lt;/h3&gt;

&lt;p&gt;Instrument to the &lt;strong&gt;OpenTelemetry GenAI conventions&lt;/strong&gt;. They are still marked Development, so expect the attribute names to change under you.&lt;/p&gt;

&lt;p&gt;Emit &lt;code&gt;gen_ai.client.token.usage&lt;/code&gt;, &lt;code&gt;gen_ai.server.time_to_first_token&lt;/code&gt;, &lt;code&gt;gen_ai.server.time_per_output_token&lt;/code&gt; and &lt;code&gt;gen_ai.execute_tool.duration&lt;/code&gt;. &lt;strong&gt;Langfuse&lt;/strong&gt; and &lt;strong&gt;Arize Phoenix&lt;/strong&gt; are both self-hostable and both fine; pick one.&lt;/p&gt;

&lt;p&gt;The thing that matters more than the tool: ship one trace ID from the gateway through to the tool call, so a single identifier ties a user complaint to a prompt, a model version, a retrieval set and a tool invocation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evals are the acceptance gate
&lt;/h3&gt;

&lt;p&gt;This is the discipline that separates a platform from a demo. &lt;strong&gt;You cannot upgrade a model you cannot evaluate.&lt;/strong&gt; Without an eval suite, every model change is a leap of faith, and the practical consequence is that you never upgrade, which means you paid for control and got stagnation.&lt;/p&gt;

&lt;p&gt;Build three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capability baselines.&lt;/strong&gt; &lt;code&gt;lm-eval&lt;/code&gt; for academic benchmarks. Useful for sanity, not for your business.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task evals on your data.&lt;/strong&gt; 200 to 500 real labeled examples from your actual workload, run in CI with &lt;code&gt;promptfoo&lt;/code&gt; or &lt;code&gt;DeepEval&lt;/code&gt;. This is the one that matters and the one people skip, because building it is unglamorous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety evals.&lt;/strong&gt; A red-team corpus of injection attempts and permission-boundary probes, run on every model or prompt change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Wire them into a pipeline that gates promotion:&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="c1"&gt;# .gitlab-ci.yml (or equivalent)&lt;/span&gt;
&lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;verify&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;promptfoo eval -c evals/task-suite.yaml --output results.json&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;promptfoo eval -c evals/injection-suite.yaml --output redteam.json&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;python scripts/gate.py results.json redteam.json&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;changes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config/models.yaml"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompts/**/*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools/**/*"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# scripts/gate.py: fail the build, not the users
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redteam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;task_pass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;successes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&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;injection_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redteam&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;successes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redteam&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&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;FAILURES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;task_pass&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;FAILURES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task pass rate &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_pass&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; below 92% floor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;injection_block&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1.00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;FAILURES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;injection suite blocked only &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;injection_block&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; must be 100%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;FAILURES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BLOCKED: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FAILURES&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASS  task=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_pass&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  injection_blocked=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;injection_block&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The injection suite threshold is 100 percent deliberately. Task quality is a tradeoff curve. A permission boundary is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Load testing
&lt;/h3&gt;

&lt;p&gt;Benchmark before you promise an SLO. &lt;strong&gt;GuideLLM&lt;/strong&gt; drives load at your actual token distributions, which is what synthetic benchmarks get wrong. Measure at target concurrency, not at concurrency 1, and set &lt;code&gt;--max-num-seqs&lt;/code&gt; from what you measured rather than what you hoped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: governance you cannot skip
&lt;/h2&gt;

&lt;h3&gt;
  
  
  EU AI Act, as it actually stands in September 2026
&lt;/h3&gt;

&lt;p&gt;The timeline moved. The &lt;strong&gt;Digital Omnibus on AI&lt;/strong&gt; deferred the high-risk deadlines; it did not cancel them. Current state:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Obligation&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2025-02-02&lt;/td&gt;
&lt;td&gt;Prohibited practices, AI literacy&lt;/td&gt;
&lt;td&gt;in force&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-08-02&lt;/td&gt;
&lt;td&gt;GPAI model obligations&lt;/td&gt;
&lt;td&gt;in force&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-02&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;AI Office enforcement powers over GPAI active&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;in force, just started&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-02&lt;/td&gt;
&lt;td&gt;Article 50 transparency (synthetic content marking)&lt;/td&gt;
&lt;td&gt;in force, grace period for pre-existing systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-12-02&lt;/td&gt;
&lt;td&gt;Article 50 grace period ends&lt;/td&gt;
&lt;td&gt;upcoming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2027-08-02&lt;/td&gt;
&lt;td&gt;Pre-2025-08-02 GPAI models must be compliant&lt;/td&gt;
&lt;td&gt;upcoming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2027-12-02&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Chapter III high-risk, Art. 6(2) / Annex III&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;deferred&lt;/strong&gt; from Aug 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2028-08-02&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Chapter III high-risk, Art. 6(1) / Annex I&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;deferred&lt;/strong&gt; from Aug 2027&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you &lt;strong&gt;deploy&lt;/strong&gt; rather than &lt;strong&gt;develop&lt;/strong&gt; GPAI models, your near-term exposure is Article 50 marking plus AI-literacy duties. High-risk obligations are fifteen months out. That is breathing room, not a pass: deciding whether your system is high-risk under Annex III takes longer than people expect, so start now.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rest
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NIST AI RMF&lt;/strong&gt; plus its Generative AI Profile is voluntary, and the most useful structure I have found for organizing the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ISO/IEC 42001&lt;/strong&gt; is the certifiable counterpart. If you sell to enterprises, expect it in questionnaires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a model inventory&lt;/strong&gt;: every model in production with source, revision SHA, license as of ingest, quantization, eval results and owner. Building it after the fact is miserable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The cost model, honestly
&lt;/h2&gt;

&lt;p&gt;Here is a worked example for the Tier 1 cluster: two nodes of 8x H200, sixteen GPUs, colocated.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Annual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Capex amortized (2 nodes ~$370k each + ~$150k network/storage/racks, 3-year straight line)&lt;/td&gt;
&lt;td&gt;~$297,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Colocation (space, power, cooling at ~$196/kW/month for ~28.6 kW IT load)&lt;/td&gt;
&lt;td&gt;~$67,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform engineering (0.5 FTE, fully loaded)&lt;/td&gt;
&lt;td&gt;~$110,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Subtotal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$474,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional: NVIDIA AI Enterprise ($4,500/GPU/yr, if you need NGC/NIM support)&lt;/td&gt;
&lt;td&gt;+$72,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is 140,160 GPU-hours a year, so roughly &lt;strong&gt;$3.38 per GPU-hour all-in&lt;/strong&gt;. Compare against committed neocloud H200 at roughly $2.75 to $3.50/GPU-hr and hyperscaler on-demand at $7.91 to $10.85.&lt;/p&gt;

&lt;p&gt;Read that carefully, because it is the conclusion most vendor content will not give you: &lt;strong&gt;at this scale, on-prem is roughly at parity with committed neocloud capacity.&lt;/strong&gt; It is decisively cheaper than hyperscaler on-demand. It is not a cost play against a well-negotiated neocloud contract.&lt;/p&gt;

&lt;p&gt;Now translate to tokens, which is what your finance team actually wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cost_per_million_output_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;annual_cost&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;throughput_tok_s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;duty_cycle&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;31.536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a measured 5,000 output tokens/second aggregate across both nodes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Duty cycle&lt;/th&gt;
&lt;th&gt;Cost per million output tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;$15.03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;$7.51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;td&gt;$4.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;$3.34&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Duty cycle dominates everything else in this model.&lt;/strong&gt; It swings your unit cost by more than 4x, which is more than any hardware choice, model choice or quantization decision available to you. Recall the Cast AI measurement of roughly 5 percent average GPU utilization across enterprise Kubernetes clusters. If that is where you land, none of this pencils.&lt;/p&gt;

&lt;p&gt;So be clear-eyed about what the number means. Private inference at sixteen GPUs is &lt;strong&gt;not&lt;/strong&gt; cheaper per token than a commodity open-model token vendor, which will sell you the same model for well under a dollar per million tokens. It &lt;strong&gt;is&lt;/strong&gt; substantially cheaper than frontier closed-model API pricing at volume. And it is the &lt;strong&gt;only&lt;/strong&gt; option when the data genuinely cannot leave. Also note that the same hardware running a 30B model rather than a 70B roughly triples throughput and cuts unit cost proportionally, so right-sizing the model to the task is the largest cost lever you control after utilization.&lt;/p&gt;

&lt;p&gt;Build this because you need control. Treat cost parity as a pleasant side effect at sufficient scale, not as the business case.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 90-day plan that works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Days 1 to 30, prove the workload.&lt;/strong&gt;&lt;br&gt;
Rent. Do not buy anything. Stand up vLLM on a rented 8-GPU node, put LiteLLM in front of it, wire OIDC, and give it to one real team with one real use case. Build the 200-example eval set from their actual work. Instrument everything. At the end of thirty days you should know your token distributions, your peak concurrency and your duty cycle, which are exactly the numbers Step 0 asked for and that nobody can guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 31 to 60, harden.&lt;/strong&gt;&lt;br&gt;
Add the guardrail model and the audit trail. Implement token exchange so user identity reaches the tools. Build the first MCP server against a system that matters, with &lt;code&gt;validate_token_resource=True&lt;/code&gt; and per-user permission trimming. Write the injection eval suite and make the CI gate real. Run GuideLLM at three times your observed peak and find where it breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 61 to 90, decide.&lt;/strong&gt;&lt;br&gt;
You now have a working platform on rented capacity and a quarter of utilization data. Run the buy-versus-rent math with your real duty cycle. If you clear 40 to 60 percent sustained and have a residency requirement, order the Tier 0 or Tier 1 hardware and start the colo and cooling conversation, remembering the 8-to-16-week OEM lead time. If you do not, sign a committed contract and revisit in two quarters. Either way you have a platform in production, which is more than most of these projects achieve in a year.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tuning, at first.&lt;/strong&gt; Almost every "we need a fine-tune" turns out to be a retrieval problem or a prompt problem. Exhaust both before you build a training pipeline, because a fine-tune is a permanent maintenance obligation attached to a model version you will want to change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building your own gateway.&lt;/strong&gt; LiteLLM is MIT-licensed and does more than your first three sprints would.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A vector database evaluation project.&lt;/strong&gt; Pick one, index, ship, measure. Retrieval quality comes overwhelmingly from chunking, hybrid search and reranking, not from the store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rack-scale NVLink systems on day one.&lt;/strong&gt; Covered above, but it bears repeating because somebody in the room will want one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-model routing before you have evals.&lt;/strong&gt; Routing between models you cannot compare is a way to make quality unmeasurable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent frameworks, for a while.&lt;/strong&gt; A gateway, a well-scoped MCP server and a loop you wrote yourself will outperform a framework you do not understand, and the security properties will be ones you can actually reason about.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;The stack is stable enough to build on: vLLM behind a gateway, open weights under a license your counsel has read, identity that survives the whole call path, MCP tools with audience-bound tokens, and evals that gate promotion. None of it is exotic in 2026, and all of it is achievable in a quarter with two good engineers.&lt;/p&gt;

&lt;p&gt;The parts that will actually determine whether you succeed are not technical. Duty cycle decides your economics. Power and cooling decide your timeline. And the discipline of building evals before you need them decides whether you can ever upgrade the model, which decides whether the platform is an asset or a monument.&lt;/p&gt;

&lt;p&gt;Two caveats on the numbers. NVIDIA and AMD publish no list prices, so treat every capex figure here as an indication and get a quote. And verify a model's license on its card, at the commit you pin, not in any table including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Primary sources for the claims that decide something, so you can check them as they change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Companion code&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://github.com/swirlai/private-llm-reference" rel="noopener noreferrer"&gt;swirlai/private-llm-reference&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serving and Kubernetes&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://github.com/vllm-project/vllm/releases/tag/v0.29.0" rel="noopener noreferrer"&gt;vLLM v0.29.0&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/release-notes.html" rel="noopener noreferrer"&gt;NVIDIA GPU Operator release notes&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://kubernetes.io/blog/2025/09/01/kubernetes-v1-34-dra-updates/" rel="noopener noreferrer"&gt;Kubernetes 1.34 DRA GA&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://llm-d.ai/" rel="noopener noreferrer"&gt;llm-d&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateways and identity&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://pypi.org/project/litellm/" rel="noopener noreferrer"&gt;LiteLLM&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://theagentrouter.ai/release-notes/" rel="noopener noreferrer"&gt;Agent Router, formerly Envoy AI Gateway&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://konghq.com/blog/product-releases/kong-ai-gateway-3-14" rel="noopener noreferrer"&gt;Kong AI Gateway 3.14&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/spiffe/spire" rel="noopener noreferrer"&gt;SPIRE&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" rel="noopener noreferrer"&gt;Spec 2026-07-28 changelog&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;Authorization spec&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices" rel="noopener noreferrer"&gt;Security best practices&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization" rel="noopener noreferrer"&gt;Enterprise-Managed Authorization, SEP-990&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://pypi.org/project/mcp/" rel="noopener noreferrer"&gt;Python SDK&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security and governance&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/" rel="noopener noreferrer"&gt;OWASP GenAI LLM Top 10 2026&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://genai.owasp.org/2025/12/09/owasp-top-10-for-agentic-applications-the-benchmark-for-agentic-security-in-the-age-of-autonomous-ai/" rel="noopener noreferrer"&gt;OWASP Top 10 for Agentic Applications&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://arxiv.org/abs/2503.18813" rel="noopener noreferrer"&gt;CaMeL&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://artificialintelligenceact.eu/implementation-timeline/" rel="noopener noreferrer"&gt;EU AI Act timeline&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://blog.sigstore.dev/model-transparency-v1.0/" rel="noopener noreferrer"&gt;Sigstore model-transparency&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Model licenses change without notice. Read the LICENSE file at the exact commit you pin, including for anything named above.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Backstage search returns `team` when you search for `tech` and what we did about it</title>
      <dc:creator>Sid Probstein</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:23:44 +0000</pubDate>
      <link>https://dev.to/sidswirl/why-backstage-search-returns-team-when-you-search-for-tech-and-what-we-did-about-it-pba</link>
      <guid>https://dev.to/sidswirl/why-backstage-search-returns-team-when-you-search-for-tech-and-what-we-did-about-it-pba</guid>
      <description>&lt;p&gt;Type "tech" into Backstage search. You get "team".&lt;/p&gt;

&lt;p&gt;We are the team behind SWIRL, and we spent this summer building a search engine module for Backstage. This is what we found, what we measured, and what we shipped. Every comparison below cites a Backstage issue number or a number we measured ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The complaint, in the maintainers' own issues
&lt;/h2&gt;

&lt;p&gt;Four issues describe the same class of problem. One line each, with the number, so you can read them yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/backstage/backstage/issues/27339" rel="noopener noreferrer"&gt;27339&lt;/a&gt;&lt;/strong&gt;: searching for "tech" returns entities named "team". The default engine adds an edit distance term to every query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/backstage/backstage/issues/6177" rel="noopener noreferrer"&gt;6177&lt;/a&gt;&lt;/strong&gt;: partial entity names do not match unless the user types a wildcard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/backstage/backstage/issues/8835" rel="noopener noreferrer"&gt;8835&lt;/a&gt;&lt;/strong&gt;: the Postgres engine cannot match part of a word at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/backstage/backstage/issues/32795" rel="noopener noreferrer"&gt;32795&lt;/a&gt;&lt;/strong&gt;: Elasticsearch support is still pinned to version 7, which reached end of life in January 2026.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are bugs in the ordinary sense. Each engine is doing exactly what it was built to do. The problem is that a service catalog is not a document corpus, and the three default engines were all chosen for other reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why a shared index cannot fix it
&lt;/h2&gt;

&lt;p&gt;The instinct is to point every portal at one big index and move on. That does not address any of the four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lunr&lt;/strong&gt; lives in the backend process memory and is rebuilt on restart. It is a browser search library doing service catalog work. Its &lt;code&gt;LunrSearchEngine&lt;/code&gt; appends an edit distance 2 term and a trailing wildcard term to every query, which is the single mechanism behind 27339. We reproduced it: "tech" returns 38 results on a stock instance, with team-a, team-d, team-c and team-b at ranks 8, 9, 10 and 11.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres&lt;/strong&gt; uses &lt;code&gt;ts_rank&lt;/code&gt; over &lt;code&gt;tsvector&lt;/code&gt;. That is a good full text engine for prose. It has no infix matching, which is issue 8835, and a catalog is almost entirely identifiers: &lt;code&gt;payment-gateway-api&lt;/code&gt;, &lt;code&gt;foo-bar.com&lt;/code&gt;, &lt;code&gt;wayback-search&lt;/code&gt;. Identifiers are the one thing &lt;code&gt;ts_rank&lt;/code&gt; was not designed for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elasticsearch&lt;/strong&gt; would solve the relevance problem. It also asks you to run a JVM cluster next to your portal, and Backstage support is stuck on version 7 per 32795, with version 7 out of support since January 2026. Most teams we talk to chose Postgres search specifically to avoid this.&lt;/p&gt;

&lt;p&gt;So the fix is not a bigger index. The fix is an index whose schema knows it is holding entity names.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The gauntlet
&lt;/h2&gt;

&lt;p&gt;We wrote nine cases before we wrote the engine. Five come straight out of the issues above. We ran them against a live Backstage on the default Lunr engine, against SWIRL in process, and finally against the published container through the real ingest and search path.&lt;/p&gt;

&lt;p&gt;The corpus is the Backstage example catalog, 71 documents, plus 5,000 synthetic entities. The synthetic set plants the entities the issues describe and 20 entities containing "team" with no "tech".&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%2F17dvwdhodf1z6k7knbkk.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%2F17dvwdhodf1z6k7knbkk.png" alt="Five gauntlet cases, default engine result on the left and SWIRL result on the right" width="800" height="448"&gt;&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;Query&lt;/th&gt;
&lt;th&gt;Default engine&lt;/th&gt;
&lt;th&gt;SWIRL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tech&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;38 results, team-a to team-d at ranks 8 to 11 (27339)&lt;/td&gt;
&lt;td&gt;tech-prefixed title at rank 1, no team-only entity in the top 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;abac&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 results, no partial match (6177, 8835)&lt;/td&gt;
&lt;td&gt;abacus at rank 1, sole hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;foo-bar.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;59 results, the first twelve all user entities&lt;/td&gt;
&lt;td&gt;foo-bar.com at rank 1, sole hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;petstore absent from all 7 results (8835)&lt;/td&gt;
&lt;td&gt;petstore in the top 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;14 results, none containing the string, led by a person&lt;/td&gt;
&lt;td&gt;zero results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wayback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;wayback-search at rank 2&lt;/td&gt;
&lt;td&gt;wayback-search at rank 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;petsotre&lt;/code&gt; (typo)&lt;/td&gt;
&lt;td&gt;petstore at rank 1&lt;/td&gt;
&lt;td&gt;petstore at rank 1, with fuzzy matching on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;wayback search&lt;/code&gt; (phrase)&lt;/td&gt;
&lt;td&gt;wayback-search at rank 1&lt;/td&gt;
&lt;td&gt;wayback-search at rank 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;service&lt;/code&gt; filtered to kind=component, lifecycle=production&lt;/td&gt;
&lt;td&gt;no attribute filter in the query API&lt;/td&gt;
&lt;td&gt;all 25 hits match both attributes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nine pass, zero fail, on the released image after our second fix pass. Two things worth saying plainly. The typo case is a genuine Lunr win: its built in edit distance finds &lt;code&gt;petstore&lt;/code&gt; from &lt;code&gt;petsotre&lt;/code&gt; with no tuning at all, and SWIRL only matches that with fuzzy matching enabled. And on the &lt;code&gt;tech&lt;/code&gt; case the shipped path puts a &lt;code&gt;tech-*-service&lt;/code&gt; entity at rank 1 rather than &lt;code&gt;tech-radar&lt;/code&gt;; our relevancy pass prefers the longer title. The assertion the case exists to protect, no team-only entity in the top 5, holds. We are recording that rather than claiming it away.&lt;/p&gt;

&lt;p&gt;The size and speed numbers, measured on 50,000 entities on one laptop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;34 MB&lt;/strong&gt; index on disk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2.7 s&lt;/strong&gt; to index all 50,000 documents, about 18,600 documents per second&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0.26 ms&lt;/strong&gt; mean query time, over 900 queries against a warm index&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Relevance was the hard part. Speed was never close to a constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two lanes
&lt;/h2&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%2F3d3cvm98eofl9kdx85ix.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%2F3d3cvm98eofl9kdx85ix.png" alt="SWIRL for Backstage architecture: Backstage collators feed the indexed lane into a Tantivy index inside the SWIRL container, while the federated lane queries GitHub and Confluence live, and both lanes merge in one ranking pipeline before returning to the Backstage search page" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The indexed lane.&lt;/strong&gt; Your collators run unchanged. The engine module streams their documents to the SWIRL ingest endpoint, which writes them into a Tantivy index, one index per Backstage document type. The schema is the whole point: a stemmed and ASCII folded &lt;code&gt;title_exact&lt;/code&gt; field boosted 3x, an n-gram &lt;code&gt;title_ngram&lt;/code&gt; field for infix matching, and a stopworded &lt;code&gt;text&lt;/code&gt; field. That n-gram field is the answer to 6177 and 8835. Ingest is upsert and delete by id, so there is no full rebuild and no transaction timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The federated lane.&lt;/strong&gt; SWIRL queries live sources per query, GitHub and Confluence today, and their results arrive in the same ranked list as &lt;code&gt;swirl-federated&lt;/code&gt; documents. Nothing is copied into an index. This is the thing a shared index genuinely cannot do, because the source of truth stays where it is and the query goes out fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Backstage keeps.&lt;/strong&gt; Its collators, its search page, and its permission filtering. SWIRL replaces the engine and nothing else. Permission fields pass through untouched, so filtering on indexed results works on day one. Your search page does not change when you swap the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Install: one container, three steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The container.&lt;/strong&gt; One &lt;code&gt;docker compose up -d&lt;/code&gt; with &lt;code&gt;swirlai/swirl-backstage:0.1.1&lt;/code&gt;. Redis runs inside the image, started by the entrypoint. SQLite holds the app database. The index lives on the &lt;code&gt;/data&lt;/code&gt; volume. No Postgres for SWIRL, no JVM, no cluster. On our smoke test it went from cold to healthy in 14 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1.&lt;/strong&gt; Edit &lt;code&gt;.yarnrc.yml&lt;/code&gt; in your app root, then install:&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;nodeLinker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-modules&lt;/span&gt;
&lt;span class="na"&gt;npmMinimalAgeGate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3d&lt;/span&gt;
&lt;span class="na"&gt;npmPreapprovedPackages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;@backstage/*'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;@swirl-search/*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn &lt;span class="nt"&gt;--cwd&lt;/span&gt; packages/backend add @swirl-search/backstage-plugin-search-backend-module-swirl@0.1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;.yarnrc.yml&lt;/code&gt; line is not optional and it is worth explaining, because we got it wrong first. &lt;code&gt;@backstage/create-app&lt;/code&gt; ships &lt;code&gt;npmMinimalAgeGate: 3d&lt;/code&gt;, a Yarn 4 supply chain control that refuses any package published in the last 72 hours unless its scope is preapproved. Only &lt;code&gt;@backstage/*&lt;/code&gt; is preapproved by default. So for three days after every release, including this one, &lt;code&gt;yarn add&lt;/code&gt; fails with YN0016 for every user on a stock app. Our own smoke test hit it 25 minutes after we published. Preapproving the scope is the Backstage native escape hatch. Do not set &lt;code&gt;npmMinimalAgeGate: 0&lt;/code&gt;, which turns the control off for every package in your repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2.&lt;/strong&gt; Wire it into &lt;code&gt;packages/backend/src/index.ts&lt;/code&gt;, and remove the Postgres module that create-app ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@backstage/plugin-search-backend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@swirl-search/backstage-plugin-search-backend-module-swirl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3.&lt;/strong&gt; Point it at the container in &lt;code&gt;app-config.yaml&lt;/code&gt;:&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;search&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;swirl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart the backend. The first index went live 3.2 seconds after backend start on our test app.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What it costs to run, honestly
&lt;/h2&gt;

&lt;p&gt;One container. About &lt;strong&gt;2.5 GiB&lt;/strong&gt; resident today, which is over our own 1 GB target and we are not going to pretend otherwise. Tantivy is about 150 MB of that. The rest is torch, presidio and litellm imported across three SWIRL processes, and trimming it is on the build plan rather than done.&lt;/p&gt;

&lt;p&gt;One replica, deployment strategy Recreate, with the index on a PersistentVolumeClaim. All your Backstage pods point at the one SWIRL service. Restarts cost nothing: the index is on disk, so SWIRL reopens it and serves again in seconds, and it never reindexes. We verified that by restarting the container mid test, with the Backstage backend left running and no collator run, and the same query returned the same 43 results.&lt;/p&gt;

&lt;p&gt;The failure mode, stated plainly: if the SWIRL pod is down, Backstage search returns an error rather than stale results. Catalog browsing is unaffected. Active passive HA is a roadmap item, not a launch promise.&lt;/p&gt;

&lt;p&gt;The image is 7 GB on disk. That is large, and it is the same reason as the memory number.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Community and Enterprise
&lt;/h2&gt;

&lt;p&gt;SWIRL for Backstage Community is free and Apache 2.0, and it is everything above: the indexed lane, the federated lane, GitHub and Confluence. The line between the editions is OAuth2. Community federated sources authenticate with one scoped service account per source, so every portal user sees whatever that account can read, and we require a scope restriction before a source will activate. Enterprise adds per-user OAuth2 identity passthrough, OAuth2-only sources, semantic cache and cross source dedup; if you need per-user permissions on federated results, contact us.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Go type "tech" into your portal
&lt;/h2&gt;

&lt;p&gt;That is the whole ask. If you run Backstage, search for "tech" and tell me what comes back. If it is a team, you have reproduced 27339, and there is now an engine that does not do that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docs: &lt;a href="https://docs.swirlaiconnect.com/backstage-overview" rel="noopener noreferrer"&gt;https://docs.swirlaiconnect.com/backstage-overview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Install: &lt;a href="https://docs.swirlaiconnect.com/backstage-install" rel="noopener noreferrer"&gt;https://docs.swirlaiconnect.com/backstage-install&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/swirlai/swirl-backstage" rel="noopener noreferrer"&gt;https://github.com/swirlai/swirl-backstage&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Plugin directory: listed on backstage.io under Search&lt;/li&gt;
&lt;li&gt;Demo video: &lt;a href="https://youtu.be/VRH0r8iaJlo" rel="noopener noreferrer"&gt;https://youtu.be/VRH0r8iaJlo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;npm is &lt;code&gt;@swirl-search/backstage-plugin-search-backend-module-swirl&lt;/code&gt; at 0.1.1, and the image is &lt;code&gt;swirlai/swirl-backstage:0.1.1&lt;/code&gt;. Issues and pull requests welcome, especially from anyone running Postgres search.&lt;/p&gt;

</description>
      <category>backstage</category>
      <category>platformengineering</category>
      <category>nlp</category>
      <category>opensource</category>
    </item>
    <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" width="710" height="399"&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" width="710" height="399"&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>
  </channel>
</rss>
