<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 23 Jul 2026 04:00:47 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Run an AI Extractability Audit on Your Site (I Found 6 Heading Tags That Cost Me Citations) ]]>
                </title>
                <description>
                    <![CDATA[ When an AI assistant answers a question, it lifts sentences from a handful of pages and cites them. Whether your page is liftable is not a mystery or a vibe. It's a set of mechanical properties of you ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-run-an-ai-extractability-audit/</link>
                <guid isPermaLink="false">6a614f37a80e58ea2984c135</guid>
                
                    <category>
                        <![CDATA[ SEO ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ web scraping ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chudi Nnorukam ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 23:16:07 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9c86b8bb-fdda-4f95-9175-623de49c584c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When an AI assistant answers a question, it lifts sentences from a handful of pages and cites them. Whether your page is liftable is not a mystery or a vibe. It's a set of mechanical properties of your HTML that you can measure, score, and fix.</p>
<p>This tutorial walks through the exact audit I ran on my own site, the six invisible heading tags it caught, the one-commit fix, and the CI gate that keeps the problem from coming back.</p>
<p>Here is the punchline up front: my homepage scored 65 out of 100 on extractability. The cause was five UI card components that rendered their titles as <code>&lt;h2&gt;</code> and <code>&lt;h3&gt;</code> tags. Demoting those six headings to ARIA-preserving paragraphs, without changing a single visible pixel or removing one word of content, took the page to 100.</p>
<p>Over the last 90 days, Microsoft's Bing Webmaster Tools reports 1,600 AI citations across 33 of my pages. Extraction is the stage of that pipeline this tutorial teaches you to audit.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-an-extractability-audit-actually-tests">What an Extractability Audit Actually Tests</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-pick-the-pages-worth-auditing">Step 1: Pick the Pages Worth Auditing</a></p>
</li>
<li><p><a href="#heading-step-2-run-the-five-checks">Step 2: Run the Five Checks</a></p>
</li>
<li><p><a href="#heading-step-3-read-your-failure-classes">Step 3: Read Your Failure Classes</a></p>
</li>
<li><p><a href="#heading-step-4-find-the-components-emitting-fake-headings">Step 4: Find the Components Emitting Fake Headings</a></p>
</li>
<li><p><a href="#heading-step-5-demote-the-headings-without-breaking-accessibility">Step 5: Demote the Headings Without Breaking Accessibility</a></p>
</li>
<li><p><a href="#heading-step-6-gate-the-fix-in-ci">Step 6: Gate the Fix in CI</a></p>
</li>
<li><p><a href="#heading-what-actually-moved">What Actually Moved</a></p>
</li>
<li><p><a href="#heading-what-i-rejected-and-why">What I Rejected, and Why</a></p>
</li>
<li><p><a href="#heading-faq">FAQ</a></p>
</li>
<li><p><a href="#heading-what-you-accomplished">What You Accomplished</a></p>
</li>
</ul>
<h2 id="heading-what-an-extractability-audit-actually-tests">What an Extractability Audit Actually Tests</h2>
<p>A citation from an AI engine is the last step of a three-stage machine pipeline, and your page has to pass every stage:</p>
<ol>
<li><p><strong>Retrieve</strong>: the engine's crawler is allowed to fetch your page, and does.</p>
</li>
<li><p><strong>Extract</strong>: the model finds a clean, self-contained answer in your markup.</p>
</li>
<li><p><strong>Attribute</strong>: the engine is confident enough about who said it to put your name next to it.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/69d995ffc8e5007ddb1e81bb/793015f2-359e-497a-b18b-4238999aa83e.png" alt="Three-stage pipeline diagram labeled Retrieve, Extract, Attribute, showing that an AI engine must fetch a page, lift a clean answer from its markup, and identify the author before a citation appears." style="display:block;margin:0 auto" width="1600" height="1000" loading="lazy">

<p>Most AI-visibility advice concentrates on stage 1 (robots.txt, sitemaps, llms.txt) and stage 3 (schema, entity signals). Stage 2 is where I've found the cheapest wins, because it's pure HTML engineering, and because it fails silently: a page that retrieves fine and attributes fine but extracts poorly simply never appears in answers, and nothing tells you why.</p>
<p><strong>Extractability</strong> is the measurable version of stage 2: can a parser walking your rendered HTML find self-contained answer blocks under clearly scoped headings? The audit in this tutorial scores that on a 0 to 100 scale using five checks, each of which you can verify by hand:</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>What it tests</th>
<th>Weight</th>
</tr>
</thead>
<tbody><tr>
<td>F1</td>
<td>The first sentence under every H2 stands alone as an answer</td>
<td>30</td>
</tr>
<tr>
<td>F2</td>
<td>The first 200 tokens of the page contain a direct answer</td>
<td>20</td>
</tr>
<tr>
<td>F3</td>
<td>Each H2 section opens with an answer in the 40 to 60 word band</td>
<td>20</td>
</tr>
<tr>
<td>F4</td>
<td>Share of H2/H3 headings phrased as questions a user would type</td>
<td>20</td>
</tr>
<tr>
<td>F5</td>
<td>An FAQ section exists at the article footer</td>
<td>10</td>
</tr>
</tbody></table>
<p>A score of 75 or above lands in the EXTRACTABLE band. 40 to 74 is PARTIALLY-EXTRACTABLE. Below 40 is NOT-EXTRACTABLE. The bands come from the AI Visibility Readiness framework I maintain, but the five checks themselves are engine-agnostic: they encode how retrieval-augmented systems chunk pages by heading, embed the chunks, and lift the opening sentences of whichever chunk matches the query.</p>
<p>The critical detail for this tutorial: <strong>the audit counts every</strong> <code>&lt;h1&gt;</code><strong>,</strong> <code>&lt;h2&gt;</code><strong>, and</strong> <code>&lt;h3&gt;</code> <strong>in your rendered DOM.</strong> Not the headings you wrote in your CMS. The headings your component library emits. That gap is where my six invisible failures lived.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>A live website you can measure and deploy (Any stack. My examples are SvelteKit, and every fix translates to React, Vue, or plain HTML.)</p>
</li>
<li><p>Python 3.10+ with <code>requests</code> and <code>beautifulsoup4</code> (<code>pip install requests beautifulsoup4</code>)</p>
</li>
<li><p>Access to your search console data (Google Search Console or Bing Webmaster Tools) to pick pages</p>
</li>
<li><p>A CI system (the example uses GitHub Actions)</p>
</li>
<li><p>About 90 minutes: 20 for the audit, 40 for the fix, 30 for the CI gate</p>
</li>
</ul>
<h2 id="heading-step-1-pick-the-pages-worth-auditing">Step 1: Pick the Pages Worth Auditing</h2>
<p>Don't audit your whole sitemap. Audit the pages that already have distribution, because extraction fixes multiply whatever retrieval you already earn.</p>
<p>Open Google Search Console, go to Performance, sort pages by impressions over the last 28 days, and look at where your distribution actually lives.</p>
<p>Here's the top of my own report from that export (July 21):</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>Impressions (28d)</th>
<th>Clicks</th>
<th>Avg position</th>
</tr>
</thead>
<tbody><tr>
<td>/blog/claude-fable-5-vs-opus-4-8</td>
<td>17,315</td>
<td>462</td>
<td>6.2</td>
</tr>
<tr>
<td>/blog/how-i-built-polymarket-trading-bot</td>
<td>13,649</td>
<td>104</td>
<td>7.6</td>
</tr>
<tr>
<td>/blog/claude-code-production-trading-bot</td>
<td>6,540</td>
<td>94</td>
<td>8.5</td>
</tr>
<tr>
<td>/blog/aeo-answer-engine-optimization-explained</td>
<td>4,189</td>
<td>1</td>
<td>8.2</td>
</tr>
</tbody></table>
<p>Individual posts dominate the impressions, but notice what every one of those posts has in common: they're all rendered by the same layout and card components.</p>
<p>Fixing a component fixes every page that uses it at once, which is why I scoped the audit to the top 3 to 5 <strong>content-index pages</strong> instead of individual posts: the homepage, your blog index, your topic or category hubs.</p>
<p>Index pages are assembled almost entirely from repeating cards, so they show component damage in its most concentrated form, and any fix propagates to everything else.</p>
<p>I chose these three:</p>
<ul>
<li><p><code>chudi.dev/</code> (the homepage)</p>
</li>
<li><p><code>chudi.dev/blog</code> (the writing index)</p>
</li>
<li><p><code>chudi.dev/topics</code> (the topic hub)</p>
</li>
</ul>
<p><strong>Artifact check:</strong> you should now have a written list of 3 to 5 URLs. That list is the audit's scope.</p>
<h2 id="heading-step-2-run-the-five-checks">Step 2: Run the Five Checks</h2>
<p>You can score the five checks with about 60 lines of Python. This is a deliberately minimal version of the auditor I run in production. It implements the two checks that catch component damage (F3 and F4) plus a full heading census, which is enough to find the class of bug this tutorial fixes.</p>
<pre><code class="language-python">import re
import sys
import requests
from bs4 import BeautifulSoup

QUESTION = re.compile(
    r"^\s*(what|how|why|when|where|who|which|is|are|can|do|does|should|will|did)\b|\?\s*$",
    re.IGNORECASE,
)

def audit(url):
    html = requests.get(url, timeout=8, headers={"User-Agent": "extract-audit/1.0"}).text
    soup = BeautifulSoup(html, "html.parser")

    headings = [(h.name, " ".join(h.get_text().split())) for h in soup.find_all(["h1", "h2", "h3"])]
    subheads = [(n, t) for n, t in headings if n in ("h2", "h3")]

    question_rate = (
        sum(1 for _, t in subheads if QUESTION.search(t)) / len(subheads) if subheads else 0.0
    )

    in_band = 0
    h2s = soup.find_all("h2")
    for h2 in h2s:
        first_p = h2.find_next("p")
        words = len(first_p.get_text().split()) if first_p else 0
        if 40 &lt;= words &lt;= 60:
            in_band += 1

    print(f"URL: {url}")
    print(f"Heading census ({len(headings)} total):")
    for name, text in headings:
        print(f"  &lt;{name}&gt; {text[:70]}")
    print(f"F4 question-format rate: {question_rate:.1%} (target &gt;= 50%)")
    print(f"F3 sections opening in the 40-60 word band: {in_band}/{len(h2s)}")

if __name__ == "__main__":
    audit(sys.argv[1])
</code></pre>
<p>Run it against each page on your list:</p>
<pre><code class="language-bash">python3 extract_audit.py https://yoursite.com/
</code></pre>
<p>The heading census is the part to stare at. It prints every H1/H2/H3 a parser sees, in order, which is frequently not the outline you think you published.</p>
<p>If you want the full five-check scored version with the weighted 0 to 100 composite, the <a href="https://citability.dev">automated audit on citability.dev</a> runs all five checks plus retrieval and attribution layers. The manual version above is enough to complete this tutorial.</p>
<p><strong>Artifact check:</strong> a terminal output per page showing the heading census, the F4 rate, and the F3 band count. Screenshot it. It is your before-state.</p>
<h2 id="heading-step-3-read-your-failure-classes">Step 3: Read Your Failure Classes</h2>
<p>Here's what the audit said about my homepage before the fix, pulled from the commit record of the remediation (2026-05-23):</p>
<ul>
<li><p>Score: <strong>65/100, PARTIALLY-EXTRACTABLE</strong>, ten points under the threshold</p>
</li>
<li><p>F4 question-format rate: <strong>26.7%</strong>, far below the 50% pass line</p>
</li>
<li><p>Cause: more than ten headings in the census that I never wrote as headings</p>
</li>
</ul>
<p>The census made the cause obvious. Alongside the section headings I had deliberately tuned ("How do I see it run live?", "What is the retrieval header?") sat a pile of statements like blog post titles and project names, each wrapped in <code>&lt;h2&gt;</code> or <code>&lt;h3&gt;</code>. I hadn't typed a single one of them into a heading field. My card components had.</p>
<p>This is the general lesson, and it is worth stating as a rule:</p>
<p><strong>The denominator is the design problem.</strong> Every heading your components emit joins the denominator of every ratio check an extraction parser runs. Ten card titles as H3s means your carefully tuned question headings are outvoted 10 to 4 by markup you never see.</p>
<p>Failure classes map to fixes like this:</p>
<table>
<thead>
<tr>
<th>Symptom in the census</th>
<th>Failure class</th>
<th>Fix (Step)</th>
</tr>
</thead>
<tbody><tr>
<td>Headings you never wrote, repeated in card-sized clusters</td>
<td>Component-emitted headings</td>
<td>Steps 4 and 5</td>
</tr>
<tr>
<td>Your own H2s are statements, not questions</td>
<td>Authored heading style</td>
<td>Rephrase to question form</td>
</tr>
<tr>
<td>Sections open with a 15-word teaser or a 120-word ramble</td>
<td>Answer-band miss</td>
<td>Densify openers to 40 to 60 words</td>
</tr>
<tr>
<td>No FAQ block</td>
<td>Missing F5 surface</td>
<td>Add one at the footer</td>
</tr>
</tbody></table>
<p>I had all four classes across my three pages. The component class was the biggest single scorer, and it's the one nobody catches by reading their CMS, so it gets the deep treatment here. (For the record, the authored fixes on my other pages were exactly what the table says: two H2s on my framework page rephrased into question form, and a topic-hub opener expanded from 37 words to roughly 50 to enter the answer band.)</p>
<p><strong>Artifact check:</strong> your census annotated with the four failure classes. Count how many headings you didn't author.</p>
<h2 id="heading-step-4-find-the-components-emitting-fake-headings">Step 4: Find the Components Emitting Fake Headings</h2>
<p>The census tells you fake headings exist. Your component library tells you where they come from. Grep for heading tags inside your component directory, not your content:</p>
<pre><code class="language-bash">grep -rn "&lt;h[23]" src/lib/components/ --include="*.svelte"
</code></pre>
<p>(React: <code>grep -rn "&lt;h[23]" src/components/ --include="*.tsx"</code>. Vue: same idea with <code>.vue</code>.)</p>
<p>On my site, this surfaced six heading sites across five components:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Emitted</th>
<th>Instances</th>
</tr>
</thead>
<tbody><tr>
<td><code>BlogCard.svelte</code></td>
<td><code>&lt;h3&gt;</code> post title</td>
<td>2</td>
</tr>
<tr>
<td><code>BlogCardFeatured.svelte</code></td>
<td><code>&lt;h2&gt;</code> post title</td>
<td>1</td>
</tr>
<tr>
<td><code>ProductCard.svelte</code></td>
<td><code>&lt;h3&gt;</code> product name</td>
<td>1</td>
</tr>
<tr>
<td><code>ProjectCard.svelte</code></td>
<td><code>&lt;h2&gt;</code> project name</td>
<td>1</td>
</tr>
<tr>
<td><code>JourneyCard.svelte</code></td>
<td><code>&lt;h2&gt;</code> milestone title</td>
<td>1</td>
</tr>
</tbody></table>
<p>Six tags doesn't sound like much until you remember that cards repeat. One blog index rendering ten <code>BlogCard</code> instances injects ten <code>&lt;h3&gt;</code> statements into that page's census. Every card-built page on the site inherits the same dilution, which is exactly why my content-index pages scored worst.</p>
<p>Why do component libraries do this? Because a card title <em>looks</em> like a heading, and because accessibility guidance rightly encourages semantic HTML.</p>
<p>The mistake is subtler: a card title is a <strong>link label into another document</strong>, not a section heading of <strong>this</strong> document. The page's real outline is "here are my featured posts", not the title of each post teased below it. HTML has no tag for "title of a different page", so components default to H2/H3, and every parser that walks the page inherits a false outline.</p>
<p><strong>Artifact check:</strong> a table like the one above: component, tag emitted, instance count. This is your fix list.</p>
<h2 id="heading-step-5-demote-the-headings-without-breaking-accessibility">Step 5: Demote the Headings Without Breaking Accessibility</h2>
<p>The obvious fix, swapping <code>&lt;h3&gt;</code> for a styled <code>&lt;span&gt;</code> or <code>&lt;p&gt;</code>, has a real cost: screen reader users navigate by heading structure, and card titles are genuinely useful landmarks when scanning a list of posts. Deleting the semantics entirely trades an AI-extraction win for an accessibility loss. That trade isn't necessary.</p>
<p>The fix that preserves both is <strong>ARIA heading demotion</strong>: replace the literal tag with a paragraph carrying <code>role="heading"</code> and an explicit <code>aria-level</code>.</p>
<p>One important clarification before the diff: the first rule of ARIA is to prefer native HTML elements, and this fix doesn't violate it. The rule applies when the text genuinely is a heading of the current document, and the whole point of Step 4 was establishing that card titles are not. They are link labels into other documents.</p>
<p>Native <code>&lt;h3&gt;</code> was the wrong semantics, while the ARIA role is a courtesy that keeps the list-scanning navigation screen reader users already rely on.</p>
<p>Here's the actual diff from my <code>BlogCard.svelte</code>, unchanged except for wrapping:</p>
<pre><code class="language-diff">-&lt;h3 class="text-[20px] md:text-[22px] font-bold leading-snug
+&lt;p role="heading" aria-level="3" class="text-[20px] md:text-[22px] font-bold leading-snug
   text-[var(--color-text-primary)]
   group-hover:text-[var(--color-primary)]
   transition-colors line-clamp-2"&gt;
   {post.title}
-&lt;/h3&gt;
+&lt;/p&gt;
</code></pre>
<p>What changes and what does not:</p>
<ul>
<li><p><strong>Assistive technology sees the same outline.</strong> <code>role="heading"</code> plus <code>aria-level="3"</code> is the ARIA-standard equivalent of an <code>&lt;h3&gt;</code>. Screen readers that navigate by heading still stop here and still announce the level.</p>
</li>
<li><p><strong>Visual styling is untouched.</strong> Every class stays on the element. Zero pixels move.</p>
</li>
<li><p><strong>Content is untouched.</strong> The fix removes zero words. This matters because most extraction advice tells you to rewrite. But this class of bug needs no rewriting.</p>
</li>
<li><p><strong>HTML-tag parsers stop counting it.</strong> Extraction pipelines chunk by literal <code>h1</code>/<code>h2</code>/<code>h3</code> elements. The card title exits the census, your authored headings get the denominator back, and the ratios you tuned start passing.</p>
</li>
</ul>
<p>Apply the same one-line change at every site on your Step 4 fix list. Mine was one commit touching five components, six occurrences.</p>
<p>Then redeploy and re-run the Step 2 audit. My homepage went from 65 to <strong>100/100 EXTRACTABLE</strong> on the post-deploy re-score, with the question-format rate recovering from 26.7% to above the 50% threshold, because the four question headings I had authored were finally the only H2/H3 population on the page.</p>
<p><strong>Artifact check:</strong> the after-audit terminal output next to your before screenshot. The heading census should now contain only headings you wrote on purpose.</p>
<h2 id="heading-step-6-gate-the-fix-in-ci">Step 6: Gate the Fix in CI</h2>
<p>Here's the uncomfortable truth about extraction scores: they drift. Content changes, components get added, or a redesign ships a new card.</p>
<p>My homepage, re-audited live while writing this tutorial (July 21), sits at 80: still EXTRACTABLE, but down from its post-fix 100, because a homepage redesign in the intervening weeks changed the section structure again. The blog index and topic hub both still score 100.</p>
<p>That drift is why the durable deliverable of this tutorial isn't the fix. It's the regression gate. Without one, the next well-meaning component ships a new <code>&lt;h2&gt;</code> and your score quietly decays. Nothing visible breaks, so nothing gets caught in review.</p>
<p>Mine runs as a GitHub Actions workflow triggered by every successful production deployment, and hard-fails if any audited URL drops out of the EXTRACTABLE band:</p>
<pre><code class="language-yaml">name: Post-Deploy Extractability Audit

on:
  deployment_status:

jobs:
  audit:
    if: |
      github.event.deployment_status.state == 'success' &amp;&amp;
      github.event.deployment.environment == 'Production'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install requests beautifulsoup4
      - name: Audit extractability on the live URLs
        run: |
          for url in "https://yoursite.com/" "https://yoursite.com/blog"; do
            python3 scripts/extract_audit.py "$url" --min-score 75 || exit 1
          done
</code></pre>
<p>To make the minimal auditor CI-ready, add a <code>--min-score</code> flag that exits nonzero below the threshold. That's a five-line change to the Step 2 script (compute the weighted score from the checks you implement, compare, <code>sys.exit(1)</code>).</p>
<p>The production version of my gate audits five URLs and stacks Lighthouse accessibility thresholds into the same workflow, so the ARIA-demotion contract from Step 5 is enforced from both directions: extraction can't regress below 75, and accessibility can't regress below 95. That pairing is the whole point. The two constraints keep each other honest.</p>
<p><strong>Artifact check:</strong> a CI run in your Actions tab that fails when you feed it <code>--min-score 101</code> (proving it can fail) and passes at 75.</p>
<h2 id="heading-what-actually-moved">What Actually Moved</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69d995ffc8e5007ddb1e81bb/f1d4463a-5517-4642-b69a-5401ae46e68d.png" alt="Bar chart of the chudi.dev homepage extractability score at three points: 65 before the May 2026 heading fix, 100 on the post-deploy re-score, and 80 on the July 21 live re-audit. A dashed line marks the extractable threshold at 75." style="display:block;margin:0 auto" width="1600" height="1000" loading="lazy">

<p>The scoreboard for my three pages, all numbers from the same instrument:</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>Before fix (May)</th>
<th>After fix</th>
<th>Live re-audit (July 21)</th>
</tr>
</thead>
<tbody><tr>
<td>Homepage</td>
<td>65 PARTIALLY-EXTRACTABLE</td>
<td>100 EXTRACTABLE</td>
<td>80 EXTRACTABLE</td>
</tr>
<tr>
<td>Blog index</td>
<td>below threshold</td>
<td>100 EXTRACTABLE</td>
<td>100 EXTRACTABLE</td>
</tr>
<tr>
<td>Topic hub</td>
<td>below threshold</td>
<td>100 EXTRACTABLE</td>
<td>100 EXTRACTABLE</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/69d995ffc8e5007ddb1e81bb/793015f2-359e-497a-b18b-4238999aa83e.png" alt="Bing Webmaster Tools AI Performance dashboard showing 1,600 total AI citations across 33 cited pages for chudi.dev over the 90 days ending July 19, 2026." style="display:block;margin:0 auto" width="1600" height="1000" loading="lazy">

<p>And the downstream metric the audit exists to serve: Bing Webmaster Tools' AI Performance report (the only first-party AI citation dashboard that currently exists. You'll find it in your BWT property under Search Performance) shows my site earning <strong>1,600 AI citations across 33 pages in the 90 days ending July 19</strong>, from Microsoft Copilot and partner assistants. That number was 671 in late April, around when this remediation arc started, and roughly 1,500 by late June.</p>
<p>A note on causality, because this is where AI-visibility content usually oversells: the citation growth is correlated with the extraction work, not cleanly attributed to it. Over the same window, I also shipped content, fixed retrieval issues, and grew regular search traffic.</p>
<p>What I can defend: the audit scores are fully causal (the same instrument, before and after, moved because of one commit), the mechanism is documented engine behavior (heading-based chunking), and the citations kept compounding after the fix. What I can't give you is a controlled experiment isolating six heading tags. Nobody really can.</p>
<h2 id="heading-what-i-rejected-and-why">What I Rejected, and Why</h2>
<p>Selection bias is the failure mode of tutorials like this one, so here's what I considered and didn't do:</p>
<ul>
<li><p><strong>Rewriting the page copy:</strong> This is standard extraction advice. But I rejected it because the census showed a structural problem, not a prose problem. My authored sections already passed. Rewriting would have burned days and muddied the measurement.</p>
</li>
<li><p><strong>Plain</strong> <code>&lt;span&gt;</code><strong>/</strong><code>&lt;p&gt;</code> <strong>demotion without ARIA:</strong> Two fewer attributes per element. I rejected this because it deletes real navigation structure for screen reader users. The audit wouldn't have noticed the difference, but people would've.</p>
</li>
<li><p><strong>Stuffing FAQ schema on every page:</strong> F5 is worth 10 points and JSON-LD is cheap. I rejected this as the <em>first</em> move because it treats the symptom with metadata while leaving the false outline in place. Schema asserts what your page means but the DOM is what gets chunked. Fix the DOM first.</p>
</li>
<li><p><strong>Auditing every page on the sitemap:</strong> Completeness is seductive. I rejected this because extraction fixes multiply retrieval, and most pages have little retrieval to multiply. Three index pages covered the highest-impression surfaces and every card component in one pass.</p>
</li>
<li><p><strong>Chasing a 100 score as a standing target:</strong> After watching my homepage drift from 100 to 80 through an unrelated redesign while staying comfortably in the EXTRACTABLE band, I set the CI gate at the 75 threshold, not at 100. Gating at perfection turns every content experiment into a CI failure and teaches your team to ignore the gate.</p>
</li>
</ul>
<h2 id="heading-faq">FAQ</h2>
<h3 id="heading-does-demoting-headings-hurt-my-regular-seo">Does demoting headings hurt my regular SEO?</h3>
<p>The headings that matter for search are the ones describing your document's own structure, and those stay untouched. What you're removing is markup that claimed <em>other documents'</em> titles as your outline.</p>
<p>My organic search impressions grew over the months following the fix. Nothing in Google's guidance requires card titles to be heading elements.</p>
<h3 id="heading-is-this-just-gaming-one-audit-script">Is this just gaming one audit script?</h3>
<p>The five checks encode how retrieval-augmented systems actually process pages: chunk by heading, embed chunks, and lift opening sentences of matching chunks. A false outline degrades that pipeline no matter whose script measures it. You're not optimizing for my auditor. Instead, you're fixing the DOM that every parser sees. The score is a proxy, which is exactly why Step 6 gates the band, not the number.</p>
<h3 id="heading-i-use-react-or-vue-not-svelte-does-anything-change">I use React or Vue, not Svelte. Does anything change?</h3>
<p>Nothing structural. The bug lives in JSX and SFC templates identically (<code>&lt;h3&gt;{title}&lt;/h3&gt;</code> inside a <code>Card.tsx</code>), the grep in Step 4 finds it, and <code>role="heading"</code> with <code>aria-level</code> works in every framework because it's plain HTML.</p>
<h3 id="heading-what-about-the-headings-inside-my-actual-articles">What about the headings inside my actual articles?</h3>
<p>Leave them as real <code>&lt;h2&gt;</code>/<code>&lt;h3&gt;</code> elements. Article body headings are your document's structure and they're precisely what should be in the census. The demotion pattern applies only to components that surface <em>other</em> pages' titles: cards, teasers, related-post widgets, and navigation panels.</p>
<h3 id="heading-how-often-should-i-re-audit">How often should I re-audit?</h3>
<p>Continuously, which is what Step 6 buys you: the CI gate re-audits on every production deployment, so you never re-audit by hand again.</p>
<p>If you skip the gate, run the Step 2 script monthly and after any change to layout components, navigation, or templates. Content edits inside a page rarely move the score much. Component and template changes are what reshape the census, and those are exactly the changes nobody thinks to re-measure. My own 100 to 80 homepage drift came from a redesign, not from writing.</p>
<h3 id="heading-my-score-is-low-but-i-have-no-card-components-now-what">My score is low but I have no card components. Now what?</h3>
<p>Then your failure class is authored, not structural: statement headings (rephrase into questions users type), openers outside the 40 to 60 word band (densify), or a missing FAQ block (add one). The census from Step 2 tells you which. The fixes are writing work rather than component work.</p>
<h2 id="heading-what-you-accomplished">What You Accomplished</h2>
<p>You measured a property of your site most owners have never seen: the heading census your components actually emit, and the extractability score it produces.</p>
<p>You traced low scores to the specific components responsible, applied a demotion pattern that satisfies extraction parsers and screen readers simultaneously, and wired a CI gate so the score can never silently regress again.</p>
<p>The wider context, from the first two guides in this series: <a href="https://www.freecodecamp.org/news/how-to-measure-your-ai-citation-rate-across-chatgpt-perplexity-and-claude">measuring your AI citation rate across engines</a> tells you whether you're being cited, and <a href="https://www.freecodecamp.org/news/a-developers-guide-to-webmcp">shipping an agent-facing surface with WebMCP</a> prepares your site for agents that act rather than read.</p>
<p>This tutorial closes the loop in the middle: making the content you already have liftable. Retrieval determines whether engines see you, attribution determines whether they name you, and extraction, the stage you just audited, determines whether there's anything clean enough to quote.</p>
<p>Run the census on your top three pages this week. If your components are voting in your outline, you now know how to take the vote back.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Kubernetes Networking Without Kubernetes: Do What the CNI Does By Hand ]]>
                </title>
                <description>
                    <![CDATA[ In this article, you'll build an accurate mental model of what a Container Network Interface (CNI) actually does. Not by reading YAML, but by doing every single step it does by hand with raw Linux ker ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-kubernetes-networking-without-kubernetes-do-what-the-cni-does-by-hand/</link>
                <guid isPermaLink="false">6a614f114250f422f9a7be1d</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ networking ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cni ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shubham Katara ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 23:15:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8fc3683f-15c8-45ff-8424-bb1b427f68e2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, you'll build an accurate mental model of what a Container Network Interface (CNI) actually does. Not by reading YAML, but by doing every single step it does by hand with raw Linux kernel primitives.</p>
<p>The Container Network Interface (CNI) is one of the great black boxes of Kubernetes. Most people who run clusters every day have never once looked inside it. They know the <em>name</em> of their CNI ("we run Calico," "we're on Cilium") the way you know the brand of the alternator in your car: as a label, not as a thing you actually understand.</p>
<p>It lives at the very bottom of the stack, beneath the kubelet, beneath your pods, quietly moving every single packet. And precisely because it never fails loudly on a good day, almost nobody learns what it does.</p>
<p>We won't run <code>helm install cilium</code>. We won't apply a single manifest. Instead, we'll wire up pod networking from scratch, feel exactly where it breaks the moment traffic tries to leave a physical machine, and fix it manually.</p>
<p>By the end, you'll understand it in your bones, not just in theory, why tools like Cilium exist and what they're really solving under the hood.</p>
<p><strong>Who this is for:</strong></p>
<ul>
<li><p>Developers, platform engineers, and SREs who use Kubernetes every day but quietly treat pod-to-pod networking as magic.</p>
</li>
<li><p>Anyone who has ever watched a pod flip to <code>Running</code> and assumed the network "just works" and wants to know what's actually happening.</p>
</li>
</ul>
<p><strong>What you'll build with your own hands:</strong></p>
<ul>
<li><p>Two isolated network namespaces wired together with a virtual cable (<code>veth</code> pair).</p>
</li>
<li><p>A three-namespace virtual switch using a Linux bridge, the same trick legacy CNIs use on a single node.</p>
</li>
<li><p>A deliberately broken two-node setup where a packet gets dropped on the floor, plus the manual fix that makes it work.</p>
</li>
<li><p>A clear picture of the three jobs every CNI does, and why cloud providers force advanced CNIs like Cilium to use overlays and eBPF.</p>
</li>
</ul>
<p><strong>Note:</strong> Every command here needs a Linux host and <code>root</code>. Run this in a throwaway VM or lab environment, not on anything you care about. The whole point is to make a mess and learn from it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-illusion-kubernetes-routes-zero-packets">The Illusion: Kubernetes Routes Zero Packets</a></p>
</li>
<li><p><a href="#heading-the-foundation-virtual-ethernet-veth-pairs">The Foundation: Virtual Ethernet (veth) Pairs</a></p>
</li>
<li><p><a href="#heading-how-to-scale-locally-with-a-linux-bridge">How to Scale Locally with a Linux Bridge</a></p>
</li>
<li><p><a href="#heading-the-multi-node-boundary-problem">The Multi-Node Boundary Problem</a></p>
</li>
<li><p><a href="#heading-how-to-fix-it-manually-with-direct-routing">How to Fix It Manually with Direct Routing</a></p>
</li>
<li><p><a href="#heading-so-what-is-a-cni-really">So What Is a CNI, Really?</a></p>
</li>
<li><p><a href="#heading-the-cloud-catch-and-why-cilium-changes-the-game">The Cloud Catch and Why Cilium Changes the Game</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you'll need:</p>
<ul>
<li><p>Two Linux VMs on the same network for the multi-node section so you can watch traffic cross a real machine boundary.</p>
</li>
<li><p>The <code>ip</code> command from the <code>iproute2</code> package (already installed on virtually every modern distro).</p>
</li>
<li><p>A basic comfort with IP addresses, subnets, and the word "gateway." You don't need to be a network engineer.</p>
</li>
<li><p><strong>No Kubernetes.</strong> That's not a typo. We're going underneath Kubernetes on purpose.</p>
</li>
</ul>
<h2 id="heading-the-illusion-kubernetes-routes-zero-packets">The Illusion: Kubernetes Routes Zero Packets</h2>
<p>Here's the uncomfortable truth most people never confront: <strong>Kubernetes can't route a single network packet.</strong></p>
<p>Not one. Kubernetes is a orchestrator. It schedules pods, watches their health, and updates state in etcd. But when it comes to actually moving a packet from one container to another, it has zero built-in capability. None.</p>
<p>So how do your pods talk to each other? They rely completely on an external agent to wire up the virtual network plumbing on every node. That agent is the <strong>Container Network Interface (CNI)</strong>. What the CNI does under the hood quietly, is what we would do ourselves to feel the pain and then the solution a CNI provides.</p>
<p>Here's the proof that it's load-bearing. Spin up a brand-new cluster with <code>kubeadm</code> and look at your nodes:</p>
<pre><code class="language-bash">$ kubectl get nodes
NAME       STATUS     ROLES                  AGE   VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION                        CONTAINER-RUNTIME
no-cni     NotReady   control-plane,master   52s   v1.35.0   192.168.117.2   &lt;none&gt;        Ubuntu 24.04.3 LTS   7.0.11-orbstack-00360-gc9bc4d96ac70   containerd://2.1.6
worker-1   NotReady   &lt;none&gt;                 46s   v1.35.0   192.168.117.3   &lt;none&gt;        Ubuntu 24.04.3 LTS   7.0.11-orbstack-00360-gc9bc4d96ac70   containerd://2.1.6
worker-2   NotReady   &lt;none&gt;                 40s   v1.35.0   192.168.117.4   &lt;none&gt;        Ubuntu 24.04.3 LTS   7.0.11-orbstack-00360-gc9bc4d96ac70   containerd://2.1.6
</code></pre>
<p><code>NotReady</code>. Every node. The control plane is healthy, etcd is up, the scheduler is alive, and the cluster still flatly refuses to be <code>Ready</code>. If you describe the node, it tells you precisely what's missing:</p>
<pre><code class="language-plaintext">Conditions:
  Type             Status  LastHeartbeatTime                 LastTransitionTime                Reason                       Message
  ----             ------  -----------------                 ------------------                ------                       -------
  Ready            False   Sat, 18 Jul 2026 10:25:51 +0200   Sat, 18 Jul 2026 10:25:20 +0200   KubeletNotReady              container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized
</code></pre>
<p>Read that again: <strong>your cluster is not</strong> <code>Ready</code> <strong>until you install a CNI.</strong> Not "mostly ready." Not "ready except for networking." <code>NotReady</code>, full stop. Until an external plugin shows up and takes responsibility for the packets Kubernetes itself refuses to touch.</p>
<p>A cluster without a CNI is a telephone exchange with no lines plugged in: every operator is present and ready, but not a single call is able to connect.</p>
<p>So what do most people do at this exact moment? They copy one line from a getting-started page:</p>
<pre><code class="language-bash">kubectl apply -f https://.../calico.yaml
</code></pre>
<p>They watch the nodes flip to <code>Ready</code>, and they move on. That's the entire relationship most engineers have with the single component that makes their cluster work. They wing it. It works, so they never ask what "it" is.</p>
<p>This matters because "the network just works" is a dangerous story to tell yourself. The moment something breaks (a pod can't reach a service, cross-node traffic vanishes, a cloud migration mysteriously blackholes packets), you're standing in front of a system you never actually understood. So let's understand it. From the bottom up.</p>
<h2 id="heading-the-foundation-virtual-ethernet-veth-pairs">The Foundation: Virtual Ethernet (veth) Pairs</h2>
<p>To understand container networking, you first have to understand how Linux isolates it.</p>
<p>When a container (or a Kubernetes pod) is created, the kernel wraps it in an isolated <strong>Network Namespace</strong> (<code>netns</code>). Think of a fresh network namespace as an island with no bridges to the mainland. By default it's completely blind to the outside world: no interfaces, no IP addresses, and no routing tables. It can't talk to anything, and nothing can talk to it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/9acf8795-df14-49e3-ad68-900308ae51a0.png" alt="A new network namespace is an island: disconnected from everything." style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>So how do we get off the island? With a kernel primitive called a <strong>Virtual Ethernet (</strong><code>veth</code><strong>) pair</strong>.</p>
<p>A <code>veth</code> pair is a virtual network cable. Whatever packet enters one end immediately pops out the other end, even if the two ends live in different namespaces. Plug one end into the island and the other end into the mainland, and suddenly you have a connection.</p>
<p>Let's wire two isolated namespaces, <code>red</code> and <code>blue</code>, directly together.</p>
<pre><code class="language-bash"># Step 1: Create the isolated network namespaces
sudo ip netns add red
sudo ip netns add blue

# Step 2: Create the virtual ethernet cable (veth pair)
sudo ip link add veth-red type veth peer name veth-blue

# Step 3: Move each end of the cable into its namespace
sudo ip link set veth-red netns red
sudo ip link set veth-blue netns blue

# Step 4: Assign IP addresses and bring the interfaces UP
sudo ip netns exec red ip addr add 10.0.0.1/24 dev veth-red
sudo ip netns exec red ip link set veth-red up

sudo ip netns exec blue ip addr add 10.0.0.2/24 dev veth-blue
sudo ip netns exec blue ip link set veth-blue up
</code></pre>
<p>Now test the connection by pinging <code>blue</code> from inside <code>red</code>:</p>
<pre><code class="language-bash">sudo ip netns exec red ping -c 2 10.0.0.2
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/33c0c06b-60a2-4ad5-b2ec-2d6acf08b922.png" alt="A new network namespace is an island: now connected with mainland using veth pairs." style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>In the end, it would look something like this:</p>
<ul>
<li><p>Isolation broken safely: The container transitions from an unreachable, isolated namespace (no IP, no routing) to an addressable endpoint (10.1.1.2) linked directly to the host network.</p>
</li>
<li><p>Bi-directional traffic flow: Packets originating inside the container can reach external public IP networks, and incoming response packets from the internet can traverse back through the host's eth0 interface (192.168.1.10) directly into the container's veth pairs.</p>
</li>
<li><p>Zero-latency in-kernel bridging: The veth pair (veth-island &lt;--&gt; veth-mainland) acts as a direct virtual pipe, allowing instant packet transit between distinct Linux network namespaces (netns) without requiring external physical hardware .</p>
</li>
</ul>
<p><strong>The verdict:</strong> the ping succeeds. You just manually wired two isolated environments together with nothing but a virtual cable.</p>
<p>But here's the problem with this approach: it scales horribly. It does not scale well because a <code>veth</code> pair is strictly point to point.</p>
<p>Following is the number of pairs need to be configured for the number of containers:</p>
<ul>
<li><p>2 containers: 1 pair</p>
</li>
<li><p>3 containers: 3 pairs</p>
</li>
<li><p>4 containers: 6 cables</p>
</li>
</ul>
<p>For ten, you'd need 45 cables to connect every pair.</p>
<ul>
<li><p>Explodes in cable count: The number of veth pairs grow quadratically as containers increase</p>
</li>
<li><p>Complex to manage: Too many interfaces, routes and rules to configure and maintain.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/c20651dd-8932-46bb-8e1b-b99846f56854.png" alt="Image illustrating the problem with single veth pairs at scale" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>This is the same reason data centers don't run a physical cable between every pair of servers. You need a switch.</p>
<h2 id="heading-how-to-scale-locally-with-a-linux-bridge">How to Scale Locally with a Linux Bridge</h2>
<p>When you need to connect more than two interfaces on a single host, you stop running cables between everything and plug everything into a central hub instead. In the Linux kernel, that hub is a <strong>Linux Bridge</strong>. It's a software Layer 2 virtual switch (you'll often see it named <code>br0</code> or <code>cni0</code>).</p>
<p>A bridge does exactly what a physical switch does: it learns MAC addresses and forwards frames across every connected interface in the same broadcast domain.</p>
<p>The pattern changes slightly. Instead of connecting namespaces directly to each other, you attach one end of a <code>veth</code> pair to the namespace, and plug the <em>other</em> end into the host's bridge.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/c70ac344-0c3b-46ef-8e6f-f81bc52224df.png" alt="Image showing how veth pairs connect islands to mainland via Linux bridge" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>Let's tear down the old setup and build a three-namespace switch: <code>red</code>, <code>blue</code>, and <code>green</code>. They'll all share one broadcast domain.</p>
<pre><code class="language-bash"># Clean up any previous configuration
sudo ip netns del red 2&gt;/dev/null || true
sudo ip netns del blue 2&gt;/dev/null || true
sudo ip netns del green 2&gt;/dev/null || true
sudo ip link del br0 2&gt;/dev/null || true

# Step 1: Create the host switch (bridge) and bring it up
sudo ip link add br0 type bridge
sudo ip link set br0 up

# Step 2: Wire namespace 1 (red) into the bridge
sudo ip netns add red
sudo ip link add veth-red type veth peer name veth-red-host
sudo ip link set veth-red netns red
sudo ip link set veth-red-host master br0
sudo ip link set veth-red-host up
sudo ip netns exec red ip addr add 10.0.0.1/24 dev veth-red
sudo ip netns exec red ip link set veth-red up

# Step 3: Wire namespace 2 (blue) into the bridge
sudo ip netns add blue
sudo ip link add veth-blue type veth peer name veth-blue-host
sudo ip link set veth-blue netns blue
sudo ip link set veth-blue-host master br0
sudo ip link set veth-blue-host up
sudo ip netns exec blue ip addr add 10.0.0.2/24 dev veth-blue
sudo ip netns exec blue ip link set veth-blue up

# Step 4: Wire namespace 3 (green) into the bridge
sudo ip netns add green
sudo ip link add veth-green type veth peer name veth-green-host
sudo ip link set veth-green netns green
sudo ip link set veth-green-host master br0
sudo ip link set veth-green-host up
sudo ip netns exec green ip addr add 10.0.0.3/24 dev veth-green
sudo ip netns exec green ip link set veth-green up
</code></pre>
<p>Because all three namespaces are connected to the shared <code>br0</code> device, they can communicate freely with each other across the virtual network switch. But how do they actually "find" each other on the network? This is where ARP comes in.</p>
<p><strong>ARP</strong> stands for Address Resolution Protocol. It's a fundamental part of local networking. When one computer (or namespace, in our case) wants to talk to another using an IP address, it needs to discover the other computer's hardware address (called a MAC address) to actually send packets on the network.</p>
<p>ARP is the system that allows this to happen — it sends out a broadcast asking "Who has IP address X? Please tell me your MAC address," and the right system answers back.</p>
<p>Thanks to ARP, all the namespaces plugged into <code>br0</code> can learn each other's MAC addresses automatically and send packets directly within their shared network segment. Let's prove it by pinging every pair, both ways:</p>
<pre><code class="language-bash"># red reaches blue and green
sudo ip netns exec red ping -c 1 10.0.0.2
sudo ip netns exec red ping -c 1 10.0.0.3

# blue reaches red and green
sudo ip netns exec blue ping -c 1 10.0.0.1
sudo ip netns exec blue ping -c 1 10.0.0.3

# green reaches red and blue
sudo ip netns exec green ping -c 1 10.0.0.1
sudo ip netns exec green ping -c 1 10.0.0.2
</code></pre>
<p>All six pings succeed. And notice there isn't a single routing rule involved anywhere. Every namespace lives in the same <code>10.0.0.0/24</code> subnet on the same Layer 2 switch, so the kernel resolves the whole mesh with plain ARP.</p>
<p>This is <em>exactly</em> how legacy single-node CNIs (the old <code>kubenet</code>) operate. On one machine, it's clean and simple.</p>
<p>But Kubernetes is a distributed system designed to scale across thousands of physical machines. So here's the question that breaks everything: what happens when our namespaces need to leave the host?</p>
<h2 id="heading-the-multi-node-boundary-problem">The Multi-Node Boundary Problem</h2>
<p>Everything so far has lived on one machine. Kubernetes doesn't. So let's do the honest thing: stand up two real VMs and watch the single-node trick fall apart. Don't take my word for it: build this and watch the packet die.</p>
<p>Here's the setup:</p>
<ul>
<li><p><strong>VM 1</strong> (host IP <code>10.1.44.216</code>): home to the <code>red</code> namespace, pod subnet <code>10.0.1.0/24</code>.</p>
</li>
<li><p><strong>VM 2</strong> (host IP <code>10.1.44.178</code>): home to the <code>blue</code> namespace, pod subnet <code>10.0.2.0/24</code>.</p>
</li>
</ul>
<p>Two things to notice before we start. First, each node gets its <strong>own</strong> pod subnet (<code>10.0.1.0/24</code> on VM 1, <code>10.0.2.0/24</code> on VM 2) because if both nodes handed out <code>10.0.0.x</code> addresses, you'd get IP collisions the instant two pods landed on the same number.</p>
<p>Second, because the subnets now differ, each namespace needs a <strong>gateway</strong> to route through, and that gateway is its own host's bridge.</p>
<p><strong>Note:</strong> this is the <a href="http://cleanup-multinode.sh">cleanup-multinode.sh</a> script that should only be used in case you make any errors while setting up the cross node routes and veth pairs.</p>
<pre><code class="language-shell">#!/usr/bin/env bash
#
# cleanup-multinode.sh
# Tears down the manual multi-node CNI lab (bridge + namespaces + veth
# pairs + cross-node static routes) from "Build a Mental Model for
# Kubernetes CNI by Doing It Manually."
#
# Safe to run on BOTH VMs. Every step is idempotent: anything that was
# never created on this host is skipped instead of erroring out, so
# re-running it is harmless.
#
# Usage:  sudo ./cleanup-multinode.sh
#
set -u

if [[ $EUID -ne 0 ]]; then
  echo "This script needs root. Run:  sudo $0" &gt;&amp;2
  exit 1
fi

echo "==&gt; Deleting network namespaces (this also destroys their veth pairs)..."
ip netns del red  2&gt;/dev/null &amp;&amp; echo "    - removed netns 'red'"  || true
ip netns del blue 2&gt;/dev/null &amp;&amp; echo "    - removed netns 'blue'" || true

echo "==&gt; Removing any orphaned host-side veth interfaces..."
ip link del veth-red-host  2&gt;/dev/null &amp;&amp; echo "    - removed veth-red-host"  || true
ip link del veth-blue-host 2&gt;/dev/null &amp;&amp; echo "    - removed veth-blue-host" || true

echo "==&gt; Deleting the bridge..."
ip link del br0 2&gt;/dev/null &amp;&amp; echo "    - removed bridge 'br0'" || true

echo "==&gt; Removing cross-node static routes..."
ip route del 10.0.1.0/24 2&gt;/dev/null &amp;&amp; echo "    - removed route to 10.0.1.0/24" || true
ip route del 10.0.2.0/24 2&gt;/dev/null &amp;&amp; echo "    - removed route to 10.0.2.0/24" || true

echo "==&gt; Disabling IP forwarding (non-persistent; resets on reboot anyway)..."
sysctl -w net.ipv4.ip_forward=0 &gt;/dev/null

# --- Optional: undo the 'Common Gotchas' tweaks, ONLY if you applied them ---
# On a throwaway lab VM, leaving FORWARD at ACCEPT or rp_filter at 0 is
# usually harmless, so these are opt-in. Uncomment whatever you changed.
# sysctl -w net.ipv4.conf.all.rp_filter=1 &gt;/dev/null
# iptables -P FORWARD DROP

echo
echo "==&gt; Teardown complete. Verifying nothing is left behind:"
echo "--- namespaces (expect: no red/blue) ---"
out=$(ip netns list);                          echo "${out:-  (none)}"
echo "--- bridges (expect: no br0) ---"
out=$(ip -br link show type bridge 2&gt;/dev/null); echo "${out:-  (none)}"
echo "--- lab routes (expect: none) ---"
out=$(ip route | grep -E '10\.0\.[12]\.0/24'); echo "${out:-  (none)}"
</code></pre>
<p><strong>On VM 1 (</strong><code>10.1.44.216</code><strong>)</strong>, build the bridge, wire up <code>red</code>, and turn the host into a router:</p>
<pre><code class="language-bash"># Make the host a router so it can transit packets that aren't its own
sudo sysctl -w net.ipv4.ip_forward=1

# Build the bridge and give it a gateway IP for VM 1's pod subnet
sudo ip link add br0 type bridge
sudo ip addr add 10.0.1.254/24 dev br0
sudo ip link set br0 up

# Wire the red namespace into the bridge
sudo ip netns add red
sudo ip link add veth-red type veth peer name veth-red-host
sudo ip link set veth-red netns red
sudo ip link set veth-red-host master br0
sudo ip link set veth-red-host up
sudo ip netns exec red ip addr add 10.0.1.1/24 dev veth-red
sudo ip netns exec red ip link set veth-red up

# Point the namespace's default route at its bridge gateway
sudo ip netns exec red ip route add default via 10.0.1.254
</code></pre>
<p><strong>On VM 2 (</strong><code>10.1.44.178</code><strong>)</strong>, do the mirror image for <code>blue</code>:</p>
<pre><code class="language-bash">sudo sysctl -w net.ipv4.ip_forward=1

sudo ip link add br0 type bridge
sudo ip addr add 10.0.2.254/24 dev br0
sudo ip link set br0 up

sudo ip netns add blue
sudo ip link add veth-blue type veth peer name veth-blue-host
sudo ip link set veth-blue netns blue
sudo ip link set veth-blue-host master br0
sudo ip link set veth-blue-host up
sudo ip netns exec blue ip addr add 10.0.2.1/24 dev veth-blue
sudo ip netns exec blue ip link set veth-blue up

sudo ip netns exec blue ip route add default via 10.0.2.254
</code></pre>
<p>Both hosts are routers now. Both namespaces are wired up. Ping <code>blue</code> on VM 2 from <code>red</code> on VM 1:</p>
<pre><code class="language-bash"># On VM 1
sudo ip netns exec red ping -c 3 10.0.2.1
</code></pre>
<pre><code class="language-plaintext">PING 10.0.2.1 (10.0.2.1) 56(84) bytes of data.

--- 10.0.2.1 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2043ms
</code></pre>
<p><strong>100% packet loss.</strong> The packet is dropped on the floor, exactly as promised, but now you've seen it with your own eyes.</p>
<p>Here's the part worth proving to yourself: the packet really does leave VM 1. It just never arrives at VM 2. Run <code>tcpdump</code> on both boxes and ping again:</p>
<pre><code class="language-bash"># Detect your physical NIC once (enp1s0, ens3, eth0, ...)
NIC=$(ip route get 1.1.1.1 | grep -oP 'dev \K\S+')

# On VM 1: the echo requests march out the door
sudo tcpdump -ni "$NIC" icmp
IP 10.0.1.1 &gt; 10.0.2.1: ICMP echo request, id 5, seq 1, length 64
IP 10.0.1.1 &gt; 10.0.2.1: ICMP echo request, id 5, seq 2, length 64

# On VM 2: dead silence. Nothing ever shows up.
sudo tcpdump -ni "$NIC" icmp
(no output)
</code></pre>
<p>So where does it die? Follow the life and death of that packet:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/c2cea829-08c0-4b1d-a449-376791f9d070.png" alt="Death of the packet cross nodes" style="display:block;margin:0 auto" width="1714" height="918" loading="lazy">

<p>To keep it even simpler, the flow is as follows:</p>
<pre><code class="language-bash">Red Pod (10.0.1.1)
      │
      ▼
eth0
      │
      ▼
veth-red
      │
      ▼
br0 (10.0.1.254)
      │
      ▼
VM 1 Routing Table
(No route for 10.0.2.0/24)
      │
      ▼
Default Route (0.0.0.0/0)
      │
      ▼
Physical NIC (eth0)
      │
      ▼
LAN Gateway (192.168.1.1)
      │
      ▼
❌ No route for 10.0.2.0/24
(Packet Dropped)
      │
      ▼
VM 2 Never Receives the Packet
</code></pre>
<p>That's the multi-node boundary problem in one sentence: <strong>your per-node scripts are completely blind to the rest of the cluster's topology.</strong> VM 1 built its island, VM 2 built its island, and neither has any idea the other exists.</p>
<h2 id="heading-how-to-fix-it-manually-with-direct-routing">How to Fix It Manually with Direct Routing</h2>
<p>The fix is almost insultingly small. VM 1 doesn't need a smarter network. It needs a <em>map</em>. We just have to tell each host one fact it's missing: "the other node's pod subnet lives behind the other node's physical IP." That's a single static route per side. Nothing gets rebuilt: the bridges, namespaces, and forwarding you set up a moment ago all stay exactly as they are.</p>
<p><strong>On VM 1 (</strong><code>10.1.44.216</code><strong>)</strong>, teach it where VM 2's pods live:</p>
<pre><code class="language-bash"># VM 2's pods (10.0.2.0/24) are reachable via VM 2's physical IP
sudo ip route add 10.0.2.0/24 via 10.1.44.178
</code></pre>
<p><strong>On VM 2 (</strong><code>10.1.44.178</code><strong>)</strong>, teach it the way back:</p>
<pre><code class="language-bash"># VM 1's pods (10.0.1.0/24) are reachable via VM 1's physical IP
sudo ip route add 10.0.1.0/24 via 10.1.44.216
</code></pre>
<p>That's it. Two lines. Re-run the exact same ping from <code>red</code> on VM 1:</p>
<pre><code class="language-bash">sudo ip netns exec red ping -c 3 10.0.2.1
</code></pre>
<pre><code class="language-plaintext">PING 10.0.2.1 (10.0.2.1) 56(84) bytes of data.
64 bytes from 10.0.2.1: icmp_seq=1 ttl=62 time=0.412 ms
64 bytes from 10.0.2.1: icmp_seq=2 ttl=62 time=0.388 ms
64 bytes from 10.0.2.1: icmp_seq=3 ttl=62 time=0.401 ms

--- 10.0.2.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss
</code></pre>
<p>It works. (See <code>ttl=62</code>? Your packet started at 64 and lost one hop on each host it was forwarded through, proof it crossed two routers to get there.) The packet now completes the full journey:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/7547a7e5-68d8-4f06-b396-d4d8c8cce15b.png" alt="Packet Journey cross nodes between namespaces" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>The simpler flow looks like:</p>
<pre><code class="language-bash">1. Pod (10.0.1.1)
        │
        ▼
2. veth-red
        │
        ▼
3. VM 1 br0 (10.0.1.254)
        │
        ▼
4. VM 1 Routing Table
   ✅ Static route:
   10.0.2.0/24 → 10.1.44.178
        │
        ▼
5. VM 1 Physical NIC
        │
        ▼
6. Direct Link
   VM 1 → VM 2
        │
        ▼
7. VM 2 Physical NIC
        │
        ▼
8. VM 2 Routing Table
   ✅ 10.0.2.0/24 is directly connected
        │
        ▼
9. VM 2 br0
        │
        ▼
10. Blue Pod (10.0.2.1)
        │
        ▼
✅ Reply follows the same path back
</code></pre>
<p>That one line changed everything. Instead of dumping the packet at your LAN's clueless gateway, VM 1 now hands it <strong>directly</strong> to VM 2, which knows exactly which local namespace owns <code>10.0.2.1</code>. The reply follows the mirror route home. You just hand-built cross-node pod networking.</p>
<p>Now sit with how painful that was. Two nodes took a stack of careful commands and a hand-written route on each side.</p>
<p>Imagine a thousand nodes, pods being created and destroyed every second, each one needing a fresh IP and a route on <em>every other node</em> in the cluster. Doing that by hand isn't just tedious. It's impossible.</p>
<h2 id="heading-so-what-is-a-cni-really">So What Is a CNI, Really?</h2>
<p>Everything you just did by hand (the namespaces, the <code>veth</code> pairs, the bridges, the IP assignment, the routes) is exactly what a Container Network Interface automates dynamically, at scale, the instant a pod is scheduled.</p>
<p>When you apply a pod manifest, the CNI plugin intercepts the lifecycle event and performs three core jobs:</p>
<ol>
<li><p><strong>Namespace and interface provisioning:</strong> It creates the network namespace, generates the <code>veth</code> pair, and attaches it to the bridge (or its own datapath), cleanly, every time, with no fat-fingered typos.</p>
</li>
<li><p><strong>IP Address Management (IPAM):</strong> It hands out unique, non-colliding subnets per node and leases an individual IP to every single container in the cluster. That "unique Pod CIDR per node" rule you set up manually? IPAM enforces it automatically.</p>
</li>
<li><p><strong>Cluster-wide route distribution:</strong> It programs the routing so every node knows how to reach pods on every other node: the static routes you wrote by hand, generated and pushed everywhere, kept in sync as nodes and pods come and go.</p>
</li>
</ol>
<p>That's the mental model. A CNI is the thing that does your dozen-command lab a thousand times a second and never makes a mistake.</p>
<h2 id="heading-the-cloud-catch-and-why-cilium-changes-the-game">The Cloud Catch and Why Cilium Changes the Game</h2>
<p>Here's the part that surprises people. The manual direct-routing approach we just built works flawlessly in a bare-metal lab. In a modern public cloud (AWS, GCP, Azure), <strong>it breaks completely.</strong></p>
<p>Why? Cloud providers don't let arbitrary IP addresses roam across their network fabric. Your <code>10.0.1.0/24</code> pod subnet means nothing to the VPC. Unless those IPs are explicitly registered through heavyweight cloud-controller API calls, the underlying network sees pod traffic as illegitimate and drops it: the exact failure from the boundary-problem section, except now the cloud itself is the thing saying "no."</p>
<p>This is where advanced CNIs like <strong>Cilium</strong> stop playing by the old rules. Instead of leaning on fragile Linux bridges and hand-written host routes, Cilium reaches for two much stronger mechanisms.</p>
<ul>
<li><p><strong>Overlay networks (VXLAN / Geneve).</strong> Cilium takes your raw pod packet and <em>encapsulates</em> it, wrapping it inside an ordinary UDP packet addressed from Node 1's physical IP to Node 2's physical IP. To the cloud provider, it looks like completely normal node-to-node host traffic, so it sails straight through every VPC restriction. Your pod's real addresses are hidden inside the envelope.</p>
</li>
<li><p><strong>eBPF kernel programmability.</strong> Traditional CNIs push every packet through the full Linux bridge path and hundreds of sequential <code>iptables</code> rules: slow, and slower as your cluster grows. Cilium replaces that entire pipeline by loading compiled eBPF programs directly into the kernel at the network-interface level. Packets get short-circuited from the pod's <code>veth</code> straight toward the physical NIC, giving you near line-rate performance and deep security visibility for free.</p>
</li>
</ul>
<p>Here's the whole progression in one table:</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Direct veth cables</th>
<th>Bridge + static routes</th>
<th>Advanced CNI (Cilium)</th>
</tr>
</thead>
<tbody><tr>
<td>Connects 2 endpoints</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Scales past a handful of pods</td>
<td>No</td>
<td>On one node only</td>
<td>Yes, cluster-wide</td>
</tr>
<tr>
<td>Crosses node boundaries</td>
<td>No</td>
<td>Manual routes per node</td>
<td>Automatic</td>
</tr>
<tr>
<td>Survives cloud VPC rules</td>
<td>No</td>
<td>No</td>
<td>Yes (VXLAN/Geneve overlay)</td>
</tr>
<tr>
<td>IP allocation</td>
<td>You, by hand</td>
<td>You, by hand</td>
<td>Automatic IPAM</td>
</tr>
<tr>
<td>Performance path</td>
<td>Kernel</td>
<td>Bridge + iptables</td>
<td>eBPF, near line-rate</td>
</tr>
<tr>
<td>Who maintains it</td>
<td>You, forever</td>
<td>You, forever</td>
<td>The CNI, automatically</td>
</tr>
</tbody></table>
<p>Look at that last column, then look at the last row. That's the entire value proposition of a CNI in two cells.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You didn't read about Kubernetes networking. You built it, broke it, and fixed it. Here's the mental model you now carry:</p>
<ol>
<li><p><strong>Kubernetes routes zero packets.</strong> It fully delegates the network to a CNI, and that CNI is doing real, physical plumbing on every node.</p>
</li>
<li><p><strong>A</strong> <code>veth</code> <strong>pair is a virtual cable</strong>, and it's the atom of container networking: great for two endpoints, useless at scale.</p>
</li>
<li><p><strong>A Linux bridge is a virtual switch</strong> that connects many namespaces on one host with nothing but Layer 2 and ARP. That's a single-node CNI in a nutshell.</p>
</li>
<li><p><strong>The node boundary is where naïve networking dies.</strong> Different subnets and an unaware physical network mean cross-node packets get dropped until <em>you</em> teach every host how to route.</p>
</li>
<li><p><strong>Static routes plus IP forwarding fix it manually</strong>, and doing that by hand for two nodes shows you instantly why nobody does it for a thousand.</p>
</li>
<li><p><strong>A CNI automates three jobs:</strong> interface provisioning, IPAM, and cluster-wide route distribution.</p>
</li>
<li><p><strong>The cloud breaks direct routing</strong>, which is precisely why Cilium leans on VXLAN/Geneve overlays and eBPF instead of bridges and <code>iptables</code>.</p>
</li>
</ol>
<p>The next time a pod flips to <code>Running</code> and the network "just works," you'll know the truth: nothing just works. A CNI just did (silently, and at a scale you now truly respect) everything you just did by hand.</p>
<p>From here, the natural next step is to tear down these scripts, deploy Cilium into a real cluster, and watch eBPF orchestrate this entire topology automatically. Having felt the manual pain first, you'll actually appreciate the elegance.</p>
<p><em>If this helped you build a clearer picture of Kubernetes networking, come say hi:</em></p>
<ul>
<li><p><em>LinkedIn:</em> <a href="https://www.linkedin.com/in/shubhamkatara/"><em>linkedin.com/in/shubhamkatara</em></a></p>
</li>
<li><p><em>YouTube:</em> <a href="https://www.youtube.com/@kubesimplify"><em>@kubesimplify</em></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Programmatic Advertising Works ]]>
                </title>
                <description>
                    <![CDATA[ Most tutorials on programmatic advertising stop at the web banner. That's a shame, because the idea gets far more interesting once you follow it off the screen and into the physical world. If you've n ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-programmatic-advertising-works/</link>
                <guid isPermaLink="false">6a614617166ef0e401b18d80</guid>
                
                    <category>
                        <![CDATA[ Advertising ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 22:37:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ead3788a-52c2-4821-b5c9-1c626c50d375.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most tutorials on <a href="https://advertising.amazon.com/blog/programmatic-advertising#1">programmatic advertising</a> stop at the web banner. That's a shame, because the idea gets far more interesting once you follow it off the screen and into the physical world.</p>
<p>If you've never worked in advertising, don't worry. You don't need any ad industry background to follow along. If you can read basic Python, you have everything you need.</p>
<p>The advertising part is just the setting. The real subject is a skill you'll use everywhere: taking a messy slice of the real world and turning it into data that software can act on.</p>
<p>In this article, you'll learn what programmatic advertising is and why it worked so well on the web. You'll see why bringing it to a billboard is really a data modeling problem, and you'll build small Python models for each step.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-what-is-programmatic-advertising">What Is Programmatic Advertising?</a></p>
</li>
<li><p><a href="#heading-why-the-web-made-programmatic-easy">Why the Web Made Programmatic Easy</a></p>
</li>
<li><p><a href="#heading-the-billboard-problem">The Billboard Problem</a></p>
</li>
<li><p><a href="#heading-step-1-resolve-entities">Step 1: Resolve Entities</a></p>
</li>
<li><p><a href="#heading-step-2-model-location-as-computed-context">Step 2: Model Location as Computed Context</a></p>
</li>
<li><p><a href="#heading-step-3-represent-availability-as-a-schedule-not-a-boolean">Step 3: Represent Availability as a Schedule, Not a Boolean</a></p>
</li>
<li><p><a href="#heading-step-4-express-price-as-a-function-not-a-number">Step 4: Express Price as a Function, Not a Number</a></p>
</li>
<li><p><a href="#heading-putting-it-together">Putting It Together</a></p>
</li>
<li><p><a href="#heading-an-exercise-to-build-the-intuition">An Exercise to Build the Intuition</a></p>
</li>
<li><p><a href="#heading-the-takeaway">The Takeaway</a></p>
</li>
</ul>
<h2 id="heading-what-is-programmatic-advertising">What Is Programmatic Advertising?</h2>
<p>Advertising has two sides. A publisher, such as a news site, has ad space to fill. And an advertiser, such as a shoe brand, wants to fill it. For decades, connecting the two meant phone calls, emails, and paperwork.</p>
<p>Programmatic advertising replaces that manual process with software. No human negotiates the placement. A system looks at an ad slot and decides, in milliseconds, whether to buy it and at what price.</p>
<p>Here's how it works when you open a web page. The page tells an ad exchange that a slot is open, along with some context about the page and the viewer. Advertisers' systems bid on the slot in a live auction. The winning ad appears before the page finishes loading. This process is called <a href="https://en.wikipedia.org/wiki/Real-time_bidding">real-time bidding</a>, and it happens billions of times a day.</p>
<p>Two quick terms will help. Inventory means the ad space a seller has to offer. An impression means one view of an ad by one person.</p>
<p>On the web, this model arrived fast and felt almost effortless. It's worth understanding why.</p>
<h2 id="heading-why-the-web-made-programmatic-easy">Why the Web Made Programmatic Easy</h2>
<p>The web made programmatic easy by accident. Every web ad slot came pre-structured. It had an address, meaning a URL and a position on the page. It sat inside a document with a known shape, the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model">Document Object Model</a>. And it could report back whether an ad had loaded and been seen.</p>
<p>The whole environment was machine-readable from birth, because machines were already serving it. When automated buying showed up, it had a clean surface to work against. The medium had already done the hard modeling work.</p>
<p>Keep that in mind. It's the key to the whole lesson.</p>
<h2 id="heading-the-billboard-problem">The Billboard Problem</h2>
<p>Now point the same idea at a billboard. None of that convenient structure exists.</p>
<p>A physical sign doesn't announce where it is or which way it faces. It doesn't say what it costs this week or whether it's even available. For most of its history, that information lived in rate cards and salespeople's memories, not in anything a program could query.</p>
<p>Here's the insight. Out-of-home advertising, the industry term for billboards, transit posters, and public screens, didn't lag the web because it was a weaker medium. It lagged because it had no machine-readable interface. The audience was always there. The structured data was not.</p>
<p>So the real work of bringing programmatic to the physical world isn't clever bidding logic. It's data modeling.</p>
<p>Let's make that concrete. There are four steps. Each one maps to a pattern you'll see in many other engineering problems.</p>
<h2 id="heading-step-1-resolve-entities">Step 1: Resolve Entities</h2>
<p>The same billboard often shows up in several vendors' datasets. Each vendor gives it a different name and slightly different coordinates. Before you can do anything else, one physical object has to become one record. This is known as <a href="https://en.wikipedia.org/wiki/Record_linkage">record linkage</a>, here with a geographic twist.</p>
<p>A simple approach: treat two records as the same panel if they sit close together and share a similar name. To measure the distance between two coordinates, you can use the <a href="https://en.wikipedia.org/wiki/Haversine_formula">haversine formula</a>.</p>
<pre><code class="language-python">from math import radians, sin, cos, asin, sqrt

def haversine_m(lat1, lon1, lat2, lon2):
    """Distance between two coordinates in meters."""
    lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
    a = sin((lat2 - lat1) / 2) ** 2 + \
        cos(lat1) * cos(lat2) * sin((lon2 - lon1) / 2) ** 2
    return 6371000 * 2 * asin(sqrt(a))

def same_panel(a, b, max_distance_m=25):
    close = haversine_m(a["lat"], a["lon"], b["lat"], b["lon"]) &lt;= max_distance_m
    similar_name = a["name"].lower().split()[0] == b["name"].lower().split()[0]
    return close and similar_name

vendor_a = {"name": "I-95 North Bulletin", "lat": 25.7907, "lon": -80.1300}
vendor_b = {"name": "I-95 Bulletin #4421", "lat": 25.7908, "lon": -80.1301}

print(same_panel(vendor_a, vendor_b))  # True – one billboard, one record
</code></pre>
<p>Real systems use fuzzier matching and more signals, but the principle is the same. Entity resolution comes first, or every later step counts the same billboard twice.</p>
<h2 id="heading-step-2-model-location-as-computed-context">Step 2: Model Location as Computed Context</h2>
<p>A coordinate is not an audience. To make a panel useful to a buying system, you have to represent it as a point with an orientation. Then you estimate who actually passes it by joining it against road and traffic data.</p>
<p>The useful value is computed, not given:</p>
<pre><code class="language-python">def estimate_impressions(panel, traffic_by_road):
    """Estimate daily impressions from road traffic data."""
    daily_vehicles = traffic_by_road[panel["road_id"]]
    # Only traffic moving toward the panel's face can see it
    facing_share = 0.5
    avg_occupancy = 1.4  # people per vehicle
    return int(daily_vehicles * facing_share * avg_occupancy)

panel = {"id": "P-4421", "road_id": "I-95-N", "facing": "south"}
traffic = {"I-95-N": 120000}

print(estimate_impressions(panel, traffic))  # 84000
</code></pre>
<p>Notice what happened. The raw record was just a point on a map. It got enriched into something a buyer can reason about: 84,000 estimated daily impressions. This pattern shows up everywhere in data work. A raw record plus outside data equals a useful field.</p>
<h2 id="heading-step-3-represent-availability-as-a-schedule-not-a-boolean">Step 3: Represent Availability as a Schedule, Not a Boolean</h2>
<p>A web ad slot is either open right now or it isn't. In code, that's a boolean: a value that is simply true or false. A billboard gets booked in date ranges, and its state changes underneath you. Inventory gets held, booked, and released. You need live data, not a snapshot.</p>
<pre><code class="language-python">from datetime import date

class PanelSchedule:
    def __init__(self):
        self.bookings = []  # list of (start, end) tuples

    def book(self, start, end):
        if not self.is_available(start, end):
            raise ValueError("Panel not available for that range")
        self.bookings.append((start, end))

    def is_available(self, start, end):
        return all(end &lt; b_start or start &gt; b_end
                   for b_start, b_end in self.bookings)

schedule = PanelSchedule()
schedule.book(date(2026, 8, 1), date(2026, 8, 14))

print(schedule.is_available(date(2026, 8, 10), date(2026, 8, 20)))  # False
print(schedule.is_available(date(2026, 8, 15), date(2026, 8, 31)))  # True
</code></pre>
<p>The lesson carries over. Whenever you model the real world, ask whether a value is truly fixed. Often it's state that changes over time. Availability, stock levels, and seat maps are all schedules pretending to be booleans.</p>
<h2 id="heading-step-4-express-price-as-a-function-not-a-number">Step 4: Express Price as a Function, Not a Number</h2>
<p>The rate card says one number. Reality prices by date, demand, and how much inventory is left:</p>
<pre><code class="language-python">def price_for(base_rate, start, weeks_out, occupancy):
    """Price a booking based on lead time and demand."""
    demand_multiplier = 1 + occupancy          # busier market, higher price
    urgency_discount = 0.9 if weeks_out &gt; 8 else 1.0  # reward early booking
    return round(base_rate * demand_multiplier * urgency_discount, 2)

print(price_for(base_rate=3000, start=date(2026, 12, 1),
                weeks_out=19, occupancy=0.85))  # 4995.0
print(price_for(base_rate=3000, start=date(2026, 8, 1),
                weeks_out=2, occupancy=0.40))   # 4200.0
</code></pre>
<p>Once price is a function, software can compare thousands of panels and dates instantly. That's exactly what automated buying needs.</p>
<h2 id="heading-putting-it-together">Putting It Together</h2>
<p>You now have resolved entities, computed audience, live availability, and dynamic pricing. With that in place, <a href="https://www.adquick.com/guides/programmatic-dooh">programmatic digital out of home</a> advertising works in the physical world the same way it does online. Software can plan a campaign across physical screens, buy them automatically, adjust in near real time, and measure the result.</p>
<p>It was never really about the web. It was about whether the inventory had a schema, meaning a defined structure that tells software what each piece of data is. Once you give the physical world a schema, the automation follows.</p>
<h2 id="heading-an-exercise-to-build-the-intuition">An Exercise to Build the Intuition</h2>
<p>You don't need special access to practice this. Take any messy, location-based public dataset and rehearse the four steps. A good source is <a href="https://www.openstreetmap.org/">OpenStreetMap</a>, which offers free data on millions of real-world places.</p>
<p>First, resolve duplicates by merging records that describe the same real-world thing. Second, enrich each point with computed context instead of treating the raw record as complete. Third, model one field as state that changes over time rather than a fixed value. Fourth, wrap the result in a clean interface, such as a class or a small API, that something else could query.</p>
<p>You won't have built an ad platform, but you'll have practiced the exact skills that let automated buying reach a new field.</p>
<h2 id="heading-the-takeaway">The Takeaway</h2>
<p>A good abstraction survives contact with the real world.</p>
<p>When you first learn a concept in a tidy setting, it's easy to think the tidiness is part of the concept. It rarely is. You only find out how general an idea is when you drag it somewhere messy and watch it still work.</p>
<p>Programmatic buying, moved off the screen and onto a wall by a road, is one of the cleaner proofs of that. Keep an eye out for the same pattern elsewhere: a rich domain waiting on nothing but a schema.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ From RPC to gRPC: Understanding Remote Procedure Calls, Protocol Buffers, and Modern Distributed Systems Communication  ]]>
                </title>
                <description>
                    <![CDATA[ Every application, at some point, needs to talk to another system. A mobile app talks to a backend. A backend service talks to a payment gateway. An authentication service talks to a user service. A d ]]>
                </description>
                <link>https://www.freecodecamp.org/news/remote-procedure-calls-protocol-buffers-and-modern-distributed-systems-communication/</link>
                <guid isPermaLink="false">6a6145d945466c5d8ca2a549</guid>
                
                    <category>
                        <![CDATA[ gRPC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RPC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 22:36:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1205581e-5729-44fa-837e-0f30981ea059.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every application, at some point, needs to talk to another system. A mobile app talks to a backend. A backend service talks to a payment gateway. An authentication service talks to a user service. A data pipeline talks to a storage layer.</p>
<p>The question is never whether systems need to communicate. The question is always how.</p>
<p>For years, REST over HTTP with JSON was the default answer. It works, it's simple, and the tooling is everywhere. But as systems grow in scale (in the number of services talking to each other, the volume of data being exchanged, and the need for real-time communication), REST starts to show its limits.</p>
<p>This is where Remote Procedure Calls, Protocol Buffers, and gRPC enter the picture.</p>
<p>In this handbook, you'll learn what RPC is and the problem it was designed to solve. You'll also understand Protocol Buffers: what they are, why they exist, and how they work.</p>
<p>You'll then see how Google combined these ideas into gRPC, one of the most powerful communication frameworks in modern distributed systems. You'll learn all four gRPC communication patterns, see code generated across multiple languages from a single contract file, and walk through a complete end-to-end Flutter implementation with production-grade concerns including authentication, error handling, and timeouts.</p>
<p>By the end, you won't just know what gRPC is. You'll understand when to use it, when not to, and how to think about service communication as a systems engineer.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-a-remote-procedure-call">What is a Remote Procedure Call</a>?</p>
</li>
<li><p><a href="#heading-the-problem-rpc-solves">The Problem RPC Solves</a></p>
</li>
<li><p><a href="#heading-why-grpc-over-rest-the-real-case">Why gRPC Over REST: The Real Case</a></p>
</li>
<li><p><a href="#heading-protocol-buffers-a-new-language-for-data">Protocol Buffers: A New Language for Data</a></p>
</li>
<li><p><a href="#heading-the-proto-file">The Proto File</a></p>
</li>
<li><p><a href="#heading-json-vs-protocol-buffers">JSON vs Protocol Buffers</a></p>
</li>
<li><p><a href="#heading-the-protoc-compiler-and-code-generation">The Protoc Compiler and Code Generation</a></p>
</li>
<li><p><a href="#heading-what-is-grpc">What is gRPC</a>?</p>
</li>
<li><p><a href="#heading-why-http2-matters-for-grpc">Why HTTP/2 Matters for gRPC</a></p>
</li>
<li><p><a href="#heading-the-four-grpc-communication-patterns">The Four gRPC Communication Patterns</a></p>
</li>
<li><p><a href="#heading-the-protobuf-repository-organizational-best-practice">The Protobuf Repository: Organizational Best Practice</a></p>
</li>
<li><p><a href="#heading-building-a-complete-grpc-system-with-dart-and-flutter">Building a Complete gRPC System with Dart and Flutter</a></p>
</li>
<li><p><a href="#heading-production-concerns">Production Concerns</a></p>
</li>
<li><p><a href="#heading-grpc-vs-rest-vs-websockets-when-to-use-what">gRPC vs REST vs WebSockets: When to Use What</a></p>
</li>
<li><p><a href="#heading-the-hybrid-architecture">The Hybrid Architecture</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-a-remote-procedure-call">What is a Remote Procedure Call?</h2>
<p>To understand RPCs, you first need to understand what a procedure call is.</p>
<p>A procedure call means invoking a procedure or function so that its code executes. For example, in Dart:</p>
<pre><code class="language-dart">double calculateTax(double amount) {
  return amount * 0.075;
}

final tax = calculateTax(50000); // local procedure call
</code></pre>
<p>You call <code>calculateTax</code>, pass an argument, and get a result back. The function lives on the same machine, in the same process, in the same memory space. This is a local procedure call.</p>
<p>A Remote Procedure Call takes this same idea and stretches it across a network. The function you're calling lives on a different machine, in a different process, and potentially in a different country. But from the caller's perspective, it feels exactly like calling a local function.</p>
<pre><code class="language-dart">// This looks like a local function call
final tax = await taxService.calculateTax(amount: 50000);

// But under the hood, this:
// 1. Serializes the argument into a binary format
// 2. Sends it over a network connection to a remote server
// 3. The server executes calculateTax with your argument
// 4. Serializes the result
// 5. Sends it back over the network
// 6. Deserializes it into a Dart object
// 7. Returns it to you as if it were local
</code></pre>
<p>The network complexity is completely hidden. You call a function. You get a result. Everything in between is handled by the RPC framework.</p>
<p>This is the fundamental idea behind RPC: make calling a remote function feel as natural as calling a local one.</p>
<h2 id="heading-the-problem-rpc-solves">The Problem RPC Solves</h2>
<p>To appreciate why RPC matters, you need to understand what the alternative looks like.</p>
<p>Without RPC, calling a remote service looks like this:</p>
<pre><code class="language-dart">Future&lt;double&gt; calculateTax(double amount) async {
  final response = await http.post(
    Uri.parse('https://tax-service.internal/api/v1/calculate'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer $token',
    },
    body: jsonEncode({'amount': amount}),
  );

  if (response.statusCode != 200) {
    throw Exception('Tax calculation failed: ${response.statusCode}');
  }

  final data = jsonDecode(response.body);
  return (data['tax'] as num).toDouble();
}
</code></pre>
<p>Every service call requires you to:</p>
<ul>
<li><p>Know and hardcode the endpoint URL</p>
</li>
<li><p>Know the correct HTTP method</p>
</li>
<li><p>Manually serialize your request to JSON</p>
</li>
<li><p>Handle HTTP status codes yourself</p>
</li>
<li><p>Manually deserialize the response from JSON</p>
</li>
<li><p>Cast dynamic types to the types you actually expect</p>
</li>
<li><p>Hope the field names in the response match what you think they are</p>
</li>
</ul>
<p>Now multiply this by every single service call in your application. An authentication service, a user service, a payment service, a notification service, a transaction service. Every one of them requires the same manual boilerplate. Every one of them introduces the possibility of a typo in a field name, a wrong status code assumption, or a JSON deserialization failure that only surfaces at runtime.</p>
<p>With RPC:</p>
<pre><code class="language-dart">// Feels like a local function call
final tax = await taxService.calculateTax(
  TaxRequest(amount: 50000),
);
// tax is already a strongly typed TaxResponse object
// No URLs. No HTTP methods. No JSON parsing. No casting.
</code></pre>
<p>The framework handles everything. The function signature is defined in a contract file that both the client and server use. The types are enforced at compile time. If the server changes the response shape, the client fails to compile before anything reaches production.</p>
<p>This is what RPC solves: it removes the accidental complexity of network communication and lets you focus on what you're actually trying to do.</p>
<h2 id="heading-why-grpc-over-rest-the-real-case">Why gRPC Over REST: The Real Case</h2>
<p>Before going into the technical details of Protocol Buffers and gRPC, it's important to make a solid case for why you'd choose gRPC over REST in specific scenarios. This isn't a claim that gRPC is always better. It's a clear look at where it genuinely wins.</p>
<h3 id="heading-large-payloads-called-by-many-internal-systems">Large Payloads Called by Many Internal Systems</h3>
<p>Consider an internal enterprise API in a large telecommunications company. A single request and response payload for a plan registration or activation flow can contain over a thousand fields. This endpoint is called by dozens of internal applications: billing systems, CRM platforms, customer-facing mobile apps, internal dashboards, and partner portals.</p>
<p>With REST and JSON, every one of those applications sends and receives that thousand-field payload as text. Field names like <code>subscription_activation_status</code>, <code>rate_plan_identifier</code>, and <code>network_provisioning_reference</code> travel over the wire as strings on every single request. A significant portion of every payload isn't data. It's labels for data.</p>
<p>With Protocol Buffers, field names never appear in the payload at all. Only field numbers and values travel over the wire. That thousand-field payload shrinks dramatically. For an endpoint called millions of times per day by dozens of systems, the bandwidth saving is enormous and translates directly to infrastructure cost reduction.</p>
<p>Beyond size, the generated client guarantee is equally important. With REST, each of those dozens of applications reads the API documentation and builds its own understanding of the contract. When the backend changes a field name or type, not every application finds out immediately. Some find out in production when they break.</p>
<p>With a shared <code>.proto</code> file, every application generates its own strongly typed client from the same source. A contract change means every application regenerates. The compiler immediately reports where the breaking change affects each codebase. Nothing reaches production in a broken state.</p>
<h3 id="heading-low-bandwidth-and-remote-network-conditions">Low Bandwidth and Remote Network Conditions</h3>
<p>This is one of the most under-appreciated advantages of gRPC in markets where network quality varies significantly.</p>
<p>In many regions, a substantial portion of mobile users are on 2G or 3G connections. On a 2G connection, bandwidth can be as low as 50 to 100 kilobits per second. A REST JSON response that is 80 kilobytes takes over 6 seconds to download on a 2G connection. The equivalent protobuf binary, which can be 3 to 10 times smaller, takes under 2 seconds.</p>
<p>That difference isn't a technical footnote. It's the line between an application that feels usable and one that feels broken to a significant portion of your users. For any product that operates in markets with variable network conditions, protobuf's binary efficiency is a direct competitive advantage.</p>
<p>Beyond size, gRPC runs over HTTP/2 which maintains a single persistent connection rather than opening a new connection for every request. On slow networks where connection establishment (the TCP handshake and TLS negotiation) can itself take hundreds of milliseconds, reusing a single connection across many calls saves significant time over a session.</p>
<h3 id="heading-microservice-to-microservice-communication">Microservice to Microservice Communication</h3>
<p>When two internal services need to communicate, you have options. A message bus like Kafka or RabbitMQ is excellent when you don't need an immediate response, when the operation can happen asynchronously, and when you're broadcasting something that happened to multiple consumers.</p>
<p>But many service-to-service calls are synchronous by nature. An authentication service needs to validate a token right now before the request proceeds. A fraud detection service needs to assess a transaction right now before the payment is authorized. A pricing service needs to calculate a rate right now before the quote is generated. These operations can't publish an event and wait.</p>
<p>For synchronous service-to-service calls at high frequency, gRPC over HTTP/2 with protobuf encoding is significantly more efficient than REST. The persistent multiplexed connection means no connection setup overhead per call. The binary encoding means no JSON serialization and deserialization overhead on every hop. The generated clients mean both services compile against the same contract.</p>
<p>At scale, when two services are calling each other thousands of times per second, these efficiency differences compound into real performance and cost differences.</p>
<h3 id="heading-managing-api-contracts-across-multiple-teams">Managing API Contracts Across Multiple Teams</h3>
<p>In a large engineering organization, multiple teams build services that others depend on. REST API contracts live in documentation. Documentation goes stale. The backend team changes a field name. The mobile team finds out when users report crashes. The data team finds out when their pipeline throws an error at 2am.</p>
<p>gRPC's protobuf repository approach transforms contract management from a documentation problem into a code problem. Contract changes go through pull requests. Every dependent team reviews the change. Breaking changes are caught at compile time. Nobody is surprised in production.</p>
<p>This governance benefit scales with team size. The larger the organization, the more valuable it becomes.</p>
<h3 id="heading-real-time-communication">Real-Time Communication</h3>
<p>REST is request-response. The client asks and the server answers. The conversation ends. For real-time features, you either poll (wasteful) or bolt on a separate WebSocket server alongside your REST API (two different systems to maintain).</p>
<p>gRPC's streaming patterns handle real-time communication natively within the same framework you use for regular calls. A live balance update, a real-time transaction notification, or a bidirectional chat session all use the same generated client, the same connection, and the same protobuf encoding as your regular unary calls.</p>
<p>One framework with all communication patterns. No separate infrastructure.</p>
<h2 id="heading-protocol-buffers-a-new-language-for-data">Protocol Buffers: A New Language for Data</h2>
<p>RPC is a concept. To implement it, you need two things: a way to define the contract between client and server, and a way to serialize data efficiently for transmission over the network.</p>
<p>This is where Protocol Buffers comes in.</p>
<p>Protocol Buffers, commonly called protobuf, is a language-neutral, platform-neutral, extensible mechanism for serializing structured data. It was developed at Google in 2001, used internally for years, and open-sourced in 2008.</p>
<h3 id="heading-the-json-problem-at-scale">The JSON Problem at Scale</h3>
<p>JSON is the dominant data format for web APIs. It's human-readable, flexible, and universally supported. For many use cases, it's the right choice.</p>
<p>But JSON has structural inefficiencies that become painful at scale.</p>
<p>Consider a user profile response:</p>
<pre><code class="language-json">{
  "id": "usr_001",
  "first_name": "John",
  "last_name": "Smith",
  "email": "john@example.com",
  "phone_number": "+2348012345678",
  "account_type": "savings",
  "balance": 500000.00,
  "currency": "NGN",
  "is_verified": true,
  "is_active": true,
  "kyc_level": 3,
  "created_at": "2024-01-15T10:30:00Z",
  "last_login": "2026-07-20T09:15:00Z"
}
</code></pre>
<p>Every field name travels over the network as a string on every single response. <code>"first_name"</code>, <code>"account_type"</code>, <code>"phone_number"</code> aren't data. They're labels for data. But they consume bytes on every request.</p>
<p>Now consider an internal enterprise API with over a thousand fields in its request and response payload, being called by dozens of internal applications thousands of times per day. A significant portion of every payload is field name strings, not actual data. The overhead accumulates into real bandwidth and processing costs.</p>
<p>Beyond size, JSON has another problem: it has no schema at the network level. Nothing prevents a backend engineer from renaming <code>"first_name"</code> to <code>"firstName"</code> in a new deployment. The client breaks at runtime in production with real users.</p>
<h3 id="heading-what-protocol-buffers-do-differently">What Protocol Buffers Do Differently</h3>
<p>Protocol Buffers solve both problems with a fundamentally different approach to data encoding.</p>
<p>Instead of encoding data as human-readable text with field names, protobuf encodes data as compact binary using only field numbers and values. Field names never travel over the network.</p>
<p>Here's the same user profile defined in protobuf:</p>
<pre><code class="language-protobuf">message UserProfile {
  string id = 1;
  string first_name = 2;
  string last_name = 3;
  string email = 4;
  string phone_number = 5;
  string account_type = 6;
  double balance = 7;
  string currency = 8;
  bool is_verified = 9;
  bool is_active = 10;
  int32 kyc_level = 11;
  string created_at = 12;
  string last_login = 13;
}
</code></pre>
<p>When protobuf encodes this data, the output is binary that no human can read. But to a machine, it's extremely compact and fast to parse. The field numbers (1, 2, 3...) identify each field. The names never appear in the encoded output at all.</p>
<p>The result: the same user profile that's approximately 280 bytes in JSON is approximately 95 bytes in protobuf. That's three times smaller. For a thousand-field enterprise payload, this difference is enormous.</p>
<p>And because the schema is defined in a <code>.proto</code> file that both client and server compile against, field name changes are caught at compile time, not at runtime.</p>
<h2 id="heading-the-proto-file">The Proto File</h2>
<p>The <code>.proto</code> file is the heart of everything in the protobuf and gRPC ecosystem. It's where you define your data models and your service contracts.</p>
<p>It's written in Protocol Buffer Language (proto3) – not Go, not Dart, not Python, not Java. You write it in any text editor. VS Code with the <code>vscode-proto3</code> extension gives you syntax highlighting, autocomplete, and inline validation.</p>
<p>Here's a complete <code>.proto</code> file for a fintech platform:</p>
<pre><code class="language-csharp">syntax = "proto3";

package banking;

option go_package = "./banking";
option java_package = "com.fintech.banking";



service BankingService {
  // Unary: one request, one response
  rpc Login (LoginRequest) returns (LoginResponse);

  // Unary: fetch user profile
  rpc GetProfile (ProfileRequest) returns (UserProfile);

  // Server streaming: real-time balance updates
  rpc WatchBalance (BalanceRequest) returns (stream BalanceResponse);

  // Server streaming: live transaction feed
  rpc StreamTransactions (TransactionRequest) returns (stream Transaction);

  // Client streaming: upload KYC documents in chunks
  rpc UploadDocument (stream DocumentChunk) returns (UploadResponse);

  // Bidirectional streaming: live chat support
  rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}



message LoginRequest {
  string email = 1;
  string password = 2;
}

message LoginResponse {
  string token = 1;
  string user_id = 2;
  int64 expires_at = 3;
}

message ProfileRequest {
  string user_id = 1;
}

message UserProfile {
  string id = 1;
  string first_name = 2;
  string last_name = 3;
  string email = 4;
  string phone_number = 5;
  string account_type = 6;
  double balance = 7;
  string currency = 8;
  bool is_verified = 9;
  int32 kyc_level = 10;
}

message BalanceRequest {
  string user_id = 1;
}

message BalanceResponse {
  double balance = 1;
  string currency = 2;
  int64 timestamp = 3;
}

message TransactionRequest {
  string user_id = 1;
  int32 limit = 2;
}

message Transaction {
  string id = 1;
  double amount = 2;
  string description = 3;
  string type = 4;
  int64 timestamp = 5;
}

message DocumentChunk {
  bytes data = 1;
  string document_type = 2;
  int32 chunk_index = 3;
  bool is_last = 4;
}

message UploadResponse {
  bool success = 1;
  string document_id = 2;
  string message = 3;
}

message ChatMessage {
  string sender_id = 1;
  string content = 2;
  int64 timestamp = 3;
}
</code></pre>
<p>Let's walk through every part of this file carefully.</p>
<h3 id="heading-the-syntax-declaration">The Syntax Declaration</h3>
<pre><code class="language-csharp">syntax = "proto3";
</code></pre>
<p>This tells the protobuf compiler which version of the Protocol Buffer language you're using. proto3 is the current standard. It must be the first non-comment line in every <code>.proto</code> file.</p>
<h3 id="heading-the-package-declaration">The Package Declaration</h3>
<pre><code class="language-csharp">package banking;
</code></pre>
<p>The package name prevents naming conflicts when you have multiple <code>.proto</code> files across different services. It functions like a namespace. If two services both define a <code>UserProfile</code> message, the package name distinguishes them: <code>banking.UserProfile</code> versus <code>auth.UserProfile</code>.</p>
<h3 id="heading-language-specific-options">Language-Specific Options</h3>
<pre><code class="language-protobuf">option go_package = "./banking";
option java_package = "com.fintech.banking";
</code></pre>
<p>These options tell the compiler how to organize the generated code for specific languages. They don't affect the proto file itself, only the generated output.</p>
<h3 id="heading-the-service-definition">The Service Definition</h3>
<pre><code class="language-csharp">service BankingService {
  rpc Login (LoginRequest) returns (LoginResponse);
  rpc WatchBalance (BalanceRequest) returns (stream BalanceResponse);
}
</code></pre>
<p>The <code>service</code> block defines the RPC contract. Think of it exactly like an abstract class in any object-oriented language. It declares what functions exist, what they accept, and what they return.</p>
<p>Each <code>rpc</code> line defines one remote procedure. The <code>stream</code> keyword before a type indicates that multiple messages will flow rather than just one.</p>
<h3 id="heading-message-definitions">Message Definitions</h3>
<pre><code class="language-csharp">message LoginRequest {
  string email = 1;
  string password = 2;
}
</code></pre>
<p>A <code>message</code> is a data structure. Think of it as a class with only fields: no methods, no logic. Each field has three parts.</p>
<p>The <strong>type</strong> can be <code>string</code>, <code>int32</code>, <code>int64</code>, <code>double</code>, <code>bool</code>, <code>bytes</code>, or another message type.</p>
<p>The <strong>name</strong> is the field name as it appears in generated code. This is for human readability only. It never appears in the binary encoding.</p>
<p>The <strong>field number</strong> (= 1, = 2, = 3) is the unique identifier that protobuf uses in the binary output instead of the field name. This is critical: once you assign a field number, you must never change it or reuse it. The binary encoding uses these numbers, not names. If you change a field number, old encoded data becomes unreadable.</p>
<p>You can safely add new fields with new numbers, remove fields (the number stays reserved, never reuse it), and rename fields (names don't appear in binary). You must never change a field number, reuse a removed field's number, or change a field's type.</p>
<h2 id="heading-json-vs-protocol-buffers">JSON vs Protocol Buffers</h2>
<p>Now that you understand both formats, let's make a direct comparison.</p>
<h3 id="heading-size-comparison">Size Comparison</h3>
<p>Let's look at the same login request in both formats:</p>
<p><strong>JSON (text):</strong></p>
<pre><code class="language-csharp">{
  "email": "john@example.com",
  "password": "securepassword123"
}
</code></pre>
<p>Approximately 55 bytes.</p>
<p><strong>Protobuf binary:</strong></p>
<p>Field 1 (email): tag + length + value bytes. Field 2 (password): tag + length + value bytes.</p>
<p>Approximately 38 bytes.</p>
<p>For a simple two-field message, the difference is modest. Now consider a thousand-field enterprise payload. Field names alone in JSON can account for 40-60% of the total payload size. In protobuf, field names contribute zero bytes to the payload.</p>
<p>On a 2G connection where bandwidth can be as low as 50 kilobits per second, the difference between an 80 kilobyte JSON response and a 15 kilobyte protobuf response is the difference between a 13-second load and a 2-second load. For users in areas with limited network infrastructure, this isn't a performance metric. It's a usability threshold.</p>
<h3 id="heading-speed-comparison">Speed Comparison</h3>
<p>Protobuf serialization and deserialization is significantly faster than JSON parsing because binary parsing requires no string tokenizing, quote handling, whitespace skipping, or type inference. The parser reads a field number, reads the value type, reads the value, and moves to the next field. It's a direct binary read.</p>
<p>JSON parsing must tokenize a string character by character, identify keys and values by their surrounding quotes and delimiters, infer types from the value format, and construct objects from dynamic maps.</p>
<p>On a mobile device handling hundreds of responses per session, this parsing difference translates to measurable CPU and battery savings.</p>
<h3 id="heading-schema-and-type-safety">Schema and Type Safety</h3>
<p>JSON has no schema enforcement at the network level. A backend can change <code>"balance"</code> to <code>"current_balance"</code> and the client only discovers this when the app crashes in production.</p>
<p>Protobuf schemas are enforced at compile time. If the <code>.proto</code> file changes in a way that breaks the client, the client fails to compile. The problem is caught before it reaches any user.</p>
<h3 id="heading-the-honest-comparison">The Honest Comparison</h3>
<table>
<thead>
<tr>
<th></th>
<th>JSON</th>
<th>Protocol Buffers</th>
</tr>
</thead>
<tbody><tr>
<td>Encoding</td>
<td>Text (UTF-8)</td>
<td>Binary</td>
</tr>
<tr>
<td>Human readable</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Payload size</td>
<td>Larger (field names included)</td>
<td>3 to 10 times smaller</td>
</tr>
<tr>
<td>Parse speed</td>
<td>Slower (text tokenizing)</td>
<td>Faster (direct binary read)</td>
</tr>
<tr>
<td>Schema enforcement</td>
<td>None at network level</td>
<td>Compile-time enforcement</td>
</tr>
<tr>
<td>Code generation</td>
<td>Optional</td>
<td>Required and automatic</td>
</tr>
<tr>
<td>Best for</td>
<td>Public APIs, human inspection</td>
<td>Internal services, high performance</td>
</tr>
</tbody></table>
<h2 id="heading-the-protoc-compiler-and-code-generation">The Protoc Compiler and Code Generation</h2>
<p>The <code>protoc</code> compiler reads your <code>.proto</code> file and generates code in any language you specify. This is where the universal contract becomes reality.</p>
<p><strong>Generating Go code (for the backend server):</strong></p>
<pre><code class="language-csharp">protoc \
  --go_out=. \
  --go-grpc_out=. \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">banking.pb.go        &lt;- the message structs
banking_grpc.pb.go   &lt;- the server interface
</code></pre>
<p><strong>Generating Dart code (for the Flutter client):</strong></p>
<pre><code class="language-csharp">protoc \
  --dart_out=grpc:lib/generated \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">lib/generated/
  banking.pb.dart        &lt;- the message classes
  banking.pbgrpc.dart    &lt;- the client stub
</code></pre>
<p><strong>Generating Python code (for a data service):</strong></p>
<pre><code class="language-csharp">protoc \
  --python_out=. \
  --grpc_python_out=. \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">banking_pb2.py         &lt;- the message classes
banking_pb2_grpc.py    &lt;- the client and server classes
</code></pre>
<p><strong>Generating TypeScript code (for a web frontend):</strong></p>
<pre><code class="language-csharp">protoc \
  --ts_out=. \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">banking.ts             &lt;- typed message classes and client
</code></pre>
<p>All of this from the same single <code>banking.proto</code> file.</p>
<p>The Go backend engineer never writes serialization code. The Flutter engineer never writes deserialization code. The Python data engineer never parses binary manually. The TypeScript web engineer never constructs HTTP requests. All of that is generated automatically from the contract that every team agreed on.</p>
<p>Here's what the generated code looks like in each language to make this concrete:</p>
<p><strong>Generated Go server interface (the backend implements this):</strong></p>
<pre><code class="language-go">// Generated — do not edit
type BankingServiceServer interface {
    Login(context.Context, *LoginRequest) (*LoginResponse, error)
    WatchBalance(*BalanceRequest, BankingService_WatchBalanceServer) error
    mustEmbedUnimplementedBankingServiceServer()
}

// The Go backend engineer writes this implementation
type bankingServer struct {
    pb.UnimplementedBankingServiceServer
}

func (s *bankingServer) Login(
    ctx context.Context,
    req *pb.LoginRequest,
) (*pb.LoginResponse, error) {
    token, err := authService.Login(req.Email, req.Password)
    if err != nil {
        return nil, status.Errorf(codes.Unauthenticated, "invalid credentials")
    }
    return &amp;pb.LoginResponse{
        Token:  token,
        UserId: user.Id,
    }, nil
}
</code></pre>
<p><strong>Generated Python client (the data team uses this):</strong></p>
<pre><code class="language-python">import grpc
import banking_pb2
import banking_pb2_grpc

channel = grpc.secure_channel(
    'api.fintech-platform.com:50051',
    grpc.ssl_channel_credentials()
)
stub = banking_pb2_grpc.BankingServiceStub(channel)

response = stub.Login(banking_pb2.LoginRequest(
    email='john@example.com',
    password='password123'
))

print(f"Token: {response.token}")
print(f"User ID: {response.user_id}")
</code></pre>
<p><strong>Generated Dart client (you use this in Flutter):</strong></p>
<pre><code class="language-dart">import 'package:grpc/grpc.dart';
import 'generated/banking.pbgrpc.dart';
import 'generated/banking.pb.dart';

final channel = ClientChannel('api.fintech-platform.com', port: 50051);
final client = BankingServiceClient(channel);

final response = await client.login(
  LoginRequest(email: 'john@example.com', password: 'password123'),
);

print('Token: ${response.token}');
print('User ID: ${response.userId}');
</code></pre>
<p>Three different languages. Three different teams. One <code>.proto</code> file. All of them are generated, strongly typed, and guaranteed to be in sync with the server.</p>
<h2 id="heading-what-is-grpc">What is gRPC?</h2>
<p>gRPC is Google's open-source Remote Procedure Call framework. It was open-sourced in 2016 and is now a Cloud Native Computing Foundation (CNCF) graduated project. This means it's been production-proven at the highest level of the cloud-native ecosystem.</p>
<p>gRPC combines three things:</p>
<ol>
<li><p><strong>Remote Procedure Calls</strong> as the programming model: calling remote functions like local ones.</p>
</li>
<li><p><strong>Protocol Buffers</strong> as the interface definition language and data serialization format: strongly typed contracts and compact binary encoding.</p>
</li>
<li><p><strong>HTTP/2</strong> as the transport protocol: multiplexed, persistent connections with binary framing.</p>
</li>
</ol>
<p>The combination of these three produces a framework that's faster than REST, more structured than WebSockets, and more powerful than any of its predecessors.</p>
<p>gRPC is used internally at Google for virtually all service-to-service communication. Netflix, Uber, Square, Dropbox, Lyft, and hundreds of other organizations use it for their internal microservice communication. Official support exists for Go, Java, Python, C++, C#, Ruby, Node.js, PHP, Dart, Kotlin, and more.</p>
<h2 id="heading-why-http2-matters-for-grpc">Why HTTP/2 Matters for gRPC</h2>
<p>gRPC is built exclusively on HTTP/2. Understanding what HTTP/2 provides is essential to understanding why gRPC performs the way it does.</p>
<p>HTTP/1.1, which powers most REST APIs, has fundamental performance constraints. Each request must complete before the next one begins on the same connection. Headers are sent as verbose text on every request. The server can't send data unless the client asks first.</p>
<p>HTTP/2 was designed to fix these constraints at the protocol level.</p>
<h3 id="heading-multiplexing">Multiplexing</h3>
<p>HTTP/2 introduces streams within a single connection. Multiple independent requests can travel over the same TCP connection simultaneously.</p>
<pre><code class="language-csharp">Single TCP connection to api.fintech-platform.com

Stream 1: Login request ---------&gt; Login response
Stream 2: Profile request -------&gt; Profile response
Stream 3: Balance request -------&gt; Balance stream (ongoing)
Stream 4: Transactions request --&gt; Transaction stream (ongoing)

All four streams active simultaneously over ONE connection
</code></pre>
<p>In HTTP/1.1, you would need four separate connections or wait for each to complete before starting the next. HTTP/2 handles all four over a single persistent connection with no waiting.</p>
<p>This is the foundation of gRPC's streaming capabilities. A persistent multiplexed connection is what allows the server to keep pushing balance updates and transaction notifications while the client continues making other calls.</p>
<h3 id="heading-binary-framing">Binary Framing</h3>
<p>HTTP/1.1 sends everything as text. HTTP/2 sends everything as binary frames. Binary is more compact and significantly faster for machines to parse.</p>
<p>Every gRPC message is broken into binary frames and sent over the HTTP/2 connection. Combined with protobuf's binary encoding, gRPC data travels in the most compact form possible at every layer.</p>
<h3 id="heading-header-compression-hpack">Header Compression (HPACK)</h3>
<p>HTTP/1.1 sends full headers on every request. An Authorization header carrying a JWT token can be 500 bytes or more, repeated on every request.</p>
<p>HTTP/2 uses HPACK compression. Headers sent on previous requests are cached. Subsequent requests only send headers that changed. The Authorization header, once sent, is referenced by a short index rather than retransmitted in full.</p>
<p>On a mobile application making dozens of authenticated requests per session, this compression is a meaningful bandwidth saving, especially on slow networks where every byte matters.</p>
<h3 id="heading-server-push">Server Push</h3>
<p>HTTP/2 allows the server to proactively send data to the client without waiting for a request. The client opens a stream and the server keeps pushing messages through it as events occur.</p>
<p>This is the mechanism behind gRPC server streaming. The client sends one <code>WatchBalance</code> request and the server pushes a new <code>BalanceResponse</code> every time the balance changes. No polling or repeated requests. The connection stays open and the server speaks whenever it has something new to say.</p>
<h2 id="heading-the-four-grpc-communication-patterns">The Four gRPC Communication Patterns</h2>
<p>This is the most important section of this article. gRPC doesn't have one communication model. It has four. Each one is defined precisely in the <code>.proto</code> file and serves different use cases.</p>
<h3 id="heading-pattern-1-unary-rpc">Pattern 1: Unary RPC</h3>
<p>One request from the client and one response from the server. This is identical to a REST API call in terms of the request-response flow.</p>
<pre><code class="language-csharp">rpc Login (LoginRequest) returns (LoginResponse);
</code></pre>
<pre><code class="language-plaintext">Client ----LoginRequest----&gt; Server
Client &lt;---LoginResponse---- Server
Done.
</code></pre>
<p><strong>When to use Unary RPC:</strong> Login, profile fetch, payment initiation, data creation, configuration retrieval: any operation that follows a simple ask-and-answer pattern.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-dart">Future&lt;LoginResponse&gt; login(String email, String password) async {
  try {
    return await _client.login(
      LoginRequest(email: email, password: password),
    );
  } on GrpcError catch (e) {
    throw _mapGrpcError(e);
  }
}
</code></pre>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-go">func (s *bankingServer) Login(
    ctx context.Context,
    req *pb.LoginRequest,
) (*pb.LoginResponse, error) {
    user, err := s.authService.Login(req.Email, req.Password)
    if err != nil {
        return nil, status.Errorf(codes.Unauthenticated, "invalid credentials: %v", err)
    }
    token, _ := s.tokenService.Generate(user.Id)
    return &amp;pb.LoginResponse{
        Token:  token,
        UserId: user.Id,
    }, nil
}
</code></pre>
<h3 id="heading-pattern-2-server-streaming-rpc">Pattern 2: Server Streaming RPC</h3>
<p>One request from the client and a continuous stream of responses from the server. The connection stays open and the server pushes messages as they become available.</p>
<pre><code class="language-csharp">rpc WatchBalance (BalanceRequest) returns (stream BalanceResponse);
rpc StreamTransactions (TransactionRequest) returns (stream Transaction);
</code></pre>
<pre><code class="language-plaintext">Client ----BalanceRequest----&gt; Server
Client &lt;---BalanceResponse---- Server (balance: 500000)
Client &lt;---BalanceResponse---- Server (balance: 495000, after a debit)
Client &lt;---BalanceResponse---- Server (balance: 995000, after a credit)
[stream stays open, server pushes on every change]
</code></pre>
<p><strong>When to use Server Streaming:</strong> Live account balance, real-time transaction notifications, live stock prices, sports scores, news feeds, system monitoring dashboards: anything where the server has an ongoing series of updates to deliver.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-csharp">Stream&lt;BalanceResponse&gt; watchBalance(String userId) {
  return _client.watchBalance(
    BalanceRequest(userId: userId),
  );
}
</code></pre>
<p>In Flutter, consume this with a <code>StreamBuilder</code>:</p>
<pre><code class="language-csharp">StreamBuilder&lt;BalanceResponse&gt;(
  stream: _dataSource.watchBalance(currentUserId),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }

    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }

    if (!snapshot.hasData) {
      return const Text('Waiting for balance...');
    }

    final balance = snapshot.data!;
    return Column(
      children: [
        Text(
          '${balance.currency} ${balance.balance.toStringAsFixed(2)}',
          style: const TextStyle(
            fontSize: 36,
            fontWeight: FontWeight.bold,
          ),
        ),
        Text(
          'Updated: ${DateTime.fromMillisecondsSinceEpoch(balance.timestamp.toInt())}',
        ),
      ],
    );
  },
)
</code></pre>
<p>Every time the server pushes a new balance, the <code>StreamBuilder</code> calls <code>builder</code> again and the widget shows the updated value. There's zero polling logic or manual refresh. The server speaks and the widget listens.</p>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-csharp">func (s *bankingServer) WatchBalance(
    req *pb.BalanceRequest,
    stream pb.BankingService_WatchBalanceServer,
) error {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()

    for {
        select {
        case &lt;-stream.Context().Done():
            return nil
        case &lt;-ticker.C:
            balance, err := s.accountService.GetBalance(req.UserId)
            if err != nil {
                return status.Errorf(codes.Internal, "failed to fetch balance: %v", err)
            }

            if err := stream.Send(&amp;pb.BalanceResponse{
                Balance:   balance.Amount,
                Currency:  balance.Currency,
                Timestamp: time.Now().UnixMilli(),
            }); err != nil {
                return err
            }
        }
    }
}
</code></pre>
<h3 id="heading-pattern-3-client-streaming-rpc">Pattern 3: Client Streaming RPC</h3>
<p>The client sends a stream of messages to the server. The server processes them all and responds once at the end.</p>
<pre><code class="language-csharp">rpc UploadDocument (stream DocumentChunk) returns (UploadResponse);
</code></pre>
<pre><code class="language-plaintext">Client ----Chunk 1 (bytes 0-1024)-----&gt; Server
Client ----Chunk 2 (bytes 1024-2048)--&gt; Server
Client ----Chunk 3 (bytes 2048-3072)--&gt; Server
Client ----Chunk 4 (last chunk)-------&gt; Server
Client &lt;---UploadResponse-------------- Server (document_id: "doc_001")
</code></pre>
<p><strong>When to use Client Streaming:</strong> Uploading large files (KYC documents, profile photos) in chunks, sending a batch of sensor readings, submitting bulk records to a server.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-dart">Future&lt;UploadResponse&gt; uploadDocument(
  List&lt;Uint8List&gt; chunks,
  String documentType,
) async {
  try {
    Stream&lt;DocumentChunk&gt; chunkStream() async* {
      for (int i = 0; i &lt; chunks.length; i++) {
        yield DocumentChunk(
          data: chunks[i],
          documentType: documentType,
          chunkIndex: i,
          isLast: i == chunks.length - 1,
        );
      }
    }

    return await _client.uploadDocument(chunkStream());
  } on GrpcError catch (e) {
    throw _mapGrpcError(e);
  }
}
</code></pre>
<p><code>chunkStream()</code> is an async generator function. The <code>async*</code> keyword means it yields values over time rather than returning a single value. Each <code>yield</code> produces one <code>DocumentChunk</code> message that gRPC sends to the server. The server receives these one by one and processes them all before sending a single <code>UploadResponse</code> at the end.</p>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-go">func (s *bankingServer) UploadDocument(
    stream pb.BankingService_UploadDocumentServer,
) error {
    var allData []byte
    var documentType string

    for {
        chunk, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            return status.Errorf(codes.Internal, "failed to receive chunk: %v", err)
        }

        allData = append(allData, chunk.Data...)
        documentType = chunk.DocumentType
    }

    docId, err := s.documentService.Store(allData, documentType)
    if err != nil {
        return status.Errorf(codes.Internal, "failed to store document: %v", err)
    }

    return stream.SendAndClose(&amp;pb.UploadResponse{
        Success:    true,
        DocumentId: docId,
        Message:    "Document uploaded successfully",
    })
}
</code></pre>
<h3 id="heading-pattern-4-bidirectional-streaming-rpc">Pattern 4: Bidirectional Streaming RPC</h3>
<p>Both the client and server stream messages simultaneously. Both sides can send at any time. Neither waits for the other.</p>
<pre><code class="language-csharp">rpc Chat (stream ChatMessage) returns (stream ChatMessage);
</code></pre>
<pre><code class="language-plaintext">Client ----"Hello"---------------------------&gt; Server
Server &lt;---"Hi, how can I help?"-------------- Client
Client ----"What is my account balance?"-----&gt; Server
Server &lt;---"Your balance is NGN 500,000"------ Client
Server &lt;---"New transaction alert: -5,000"---- Client (server-initiated)
Client ----"Thanks"--------------------------&gt; Server
[both sides communicate freely and simultaneously]
</code></pre>
<p><strong>When to use Bidirectional Streaming:</strong> Real-time chat, live collaborative document editing, multiplayer game state synchronization, interactive trading terminals, real-time customer support sessions.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-csharp">void startChat(String userId) {
  final outgoing = StreamController&lt;ChatMessage&gt;();

  final incoming = _client.chat(outgoing.stream);

  incoming.listen(
    (message) {
      print('${message.senderId}: ${message.content}');
    },
    onError: (error) {
      print('Chat error: $error');
    },
    onDone: () {
      print('Chat session ended');
    },
  );

  outgoing.add(ChatMessage(
    senderId: userId,
    content: 'Hello, I need help with my account',
    timestamp: DateTime.now().millisecondsSinceEpoch,
  ));
}
</code></pre>
<p><code>StreamController</code> manages the outgoing message stream. You add messages to <code>outgoing</code> whenever the user sends something. The incoming stream delivers messages from the server. Both run simultaneously over the same HTTP/2 connection.</p>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-go">func (s *bankingServer) Chat(stream pb.BankingService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }

        response := s.chatService.Process(msg)
        if err := stream.Send(&amp;pb.ChatMessage{
            SenderId:  "support_agent",
            Content:   response,
            Timestamp: time.Now().UnixMilli(),
        }); err != nil {
            return err
        }
    }
}
</code></pre>
<h2 id="heading-the-protobuf-repository-organizational-best-practice">The Protobuf Repository: Organizational Best Practice</h2>
<p>In a small project, the <code>.proto</code> file can live inside the backend repository. The mobile engineer clones the backend repo to get it. This works at small scale.</p>
<p>In any organization of meaningful size, this approach breaks down. The backend repo becomes the source of truth, giving the backend team unilateral control over the contract. Other teams find out about changes when their builds break.</p>
<p>The industry best practice is a dedicated protobuf repository: a standalone repository that belongs to everyone and is owned exclusively by no one.</p>
<pre><code class="language-plaintext">fintech-api-contracts/
  proto/
    auth/
      auth.proto
    banking/
      banking.proto
    payments/
      payments.proto
    notifications/
      notifications.proto
    kyc/
      kyc.proto
  scripts/
    generate_dart.sh
    generate_go.sh
    generate_python.sh
  README.md
</code></pre>
<h3 id="heading-how-contract-changes-work">How Contract Changes Work</h3>
<p>Every API change follows the same process:</p>
<pre><code class="language-plaintext">Engineer proposes a change to banking.proto
          |
          raises a Pull Request in fintech-api-contracts
          |
Flutter team lead reviews:
  "Does this break our client? Do we need to update?"

Go backend lead reviews:
  "Is this implementable? Does it follow our conventions?"

React web lead reviews:
  "Does the web client need changes?"

Python data lead reviews:
  "Does this affect our data pipelines?"
          |
All teams approve
          |
PR merges — the change is now the law
          |
Every team runs their code generation script
          |
Builds fail where breaking changes exist
Changes are caught at compile time
Before any code reaches production
</code></pre>
<p>This process gives you something REST with documentation can never provide: guaranteed contract synchronization across every team, enforced by the compiler, before anything reaches users.</p>
<h2 id="heading-building-a-complete-grpc-system-with-dart-and-flutter">Building a Complete gRPC System with Dart and Flutter</h2>
<p>Now let's put everything together in a complete, production-structured example.</p>
<h3 id="heading-project-setup">Project Setup</h3>
<p>Add the gRPC dependency to your Flutter project:</p>
<pre><code class="language-yaml"># pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  grpc: ^3.2.4
  protobuf: ^3.1.0

dev_dependencies:
  protoc_plugin: ^21.1.2
</code></pre>
<p>Install the protoc compiler and the Dart plugin:</p>
<pre><code class="language-yaml"># macOS
brew install protobuf

# Install the Dart protoc plugin
dart pub global activate protoc_plugin
</code></pre>
<p>Generate the Dart code from the proto file:</p>
<pre><code class="language-bash">protoc \
  --dart_out=grpc:lib/generated \
  -I proto \
  proto/banking/banking.proto
</code></pre>
<h3 id="heading-the-data-source-layer">The Data Source Layer</h3>
<pre><code class="language-dart">// lib/features/banking/data/datasources/banking_remote_datasource.dart

import 'package:grpc/grpc.dart';
import '../../../../generated/banking.pb.dart';
import '../../../../generated/banking.pbgrpc.dart';
import '../../../../core/error/app_exception.dart';

class BankingRemoteDataSource {
  late final BankingServiceClient _client;
  late final ClientChannel _channel;

  BankingRemoteDataSource({
    required String host,
    required int port,
    required String authToken,
  }) {
   
    _channel = ClientChannel(
      host,
      port: port,
      options: const ChannelOptions(
        credentials: ChannelCredentials.secure(),
        connectionTimeout: Duration(seconds: 10),
      ),
    );

   
    _client = BankingServiceClient(
      _channel,
      options: CallOptions(
        metadata: {'authorization': 'Bearer $authToken'},
        timeout: const Duration(seconds: 30),
      ),
    );
  }

  Future&lt;LoginResponse&gt; login(String email, String password) async {
    try {
      return await _client.login(
        LoginRequest(email: email, password: password),
      );
    } on GrpcError catch (e) {
      throw _mapGrpcError(e);
    }
  }

  Future&lt;UserProfile&gt; getProfile(String userId) async {
    try {
      return await _client.getProfile(
        ProfileRequest(userId: userId),
      );
    } on GrpcError catch (e) {
      throw _mapGrpcError(e);
    }
  }

  Stream&lt;BalanceResponse&gt; watchBalance(String userId) {
    return _client
        .watchBalance(BalanceRequest(userId: userId))
        .handleError((error) {
      if (error is GrpcError) throw _mapGrpcError(error);
      throw error;
    });
  }

  Stream&lt;Transaction&gt; streamTransactions(String userId, {int limit = 20}) {
    return _client
        .streamTransactions(
          TransactionRequest(userId: userId, limit: limit),
        )
        .handleError((error) {
      if (error is GrpcError) throw _mapGrpcError(error);
      throw error;
    });
  }

  Future&lt;UploadResponse&gt; uploadDocument(
    List&lt;Uint8List&gt; chunks,
    String documentType,
  ) async {
    try {
      Stream&lt;DocumentChunk&gt; chunkStream() async* {
        for (int i = 0; i &lt; chunks.length; i++) {
          yield DocumentChunk(
            data: chunks[i],
            documentType: documentType,
            chunkIndex: i,
            isLast: i == chunks.length - 1,
          );
        }
      }
      return await _client.uploadDocument(chunkStream());
    } on GrpcError catch (e) {
      throw _mapGrpcError(e);
    }
  }

  ResponseStream&lt;ChatMessage&gt; startChat(Stream&lt;ChatMessage&gt; outgoing) {
    return _client.chat(outgoing);
  }

  Future&lt;void&gt; dispose() async {
    await _channel.shutdown();
  }

  AppException _mapGrpcError(GrpcError error) {
    switch (error.code) {
      case StatusCode.unauthenticated:
        return AppException.unauthorized(
          message: error.message ?? 'Unauthorized',
        );
      case StatusCode.notFound:
        return AppException.notFound(
          message: error.message ?? 'Not found',
        );
      case StatusCode.deadlineExceeded:
        return AppException.timeout(message: 'Request timed out');
      case StatusCode.unavailable:
        return AppException.serverUnavailable(
          message: 'Service unavailable',
        );
      default:
        return AppException.unknown(
          message: error.message ?? 'Unknown error',
        );
    }
  }
}
</code></pre>
<p>Let's walk through the important decisions in this data source.</p>
<h4 id="heading-the-channel">The Channel:</h4>
<pre><code class="language-csharp">_channel = ClientChannel(
  host,
  port: port,
  options: const ChannelOptions(
    credentials: ChannelCredentials.secure(),
    connectionTimeout: Duration(seconds: 10),
  ),
);
</code></pre>
<p>The channel is the physical HTTP/2 connection to the server. You create it once and reuse it for every call. <code>ChannelCredentials.secure()</code> enables TLS encryption. <code>connectionTimeout</code> prevents the app from waiting indefinitely if the server is unreachable.</p>
<p>The channel is the core of gRPC's performance advantage. A single persistent channel multiplexes all requests through one HTTP/2 connection. Creating a new channel per request would eliminate this advantage entirely and perform worse than REST.</p>
<h4 id="heading-authentication-via-metadata">Authentication via Metadata:</h4>
<pre><code class="language-dart">_client = BankingServiceClient(
  _channel,
  options: CallOptions(
    metadata: {'authorization': 'Bearer $authToken'},
    timeout: const Duration(seconds: 30),
  ),
);
</code></pre>
<p>gRPC uses metadata (key-value pairs) for what HTTP uses headers. Passing the auth token as metadata on the <code>CallOptions</code> means every single call made through this client automatically includes the Authorization metadata. You write it once. It applies everywhere.</p>
<h4 id="heading-error-mapping">Error Mapping:</h4>
<pre><code class="language-csharp">AppException _mapGrpcError(GrpcError error) {
  switch (error.code) {
    case StatusCode.unauthenticated:
      return AppException.unauthorized(...);
    case StatusCode.deadlineExceeded:
      return AppException.timeout(...);
    ...
  }
}
</code></pre>
<p>gRPC has its own set of status codes similar to HTTP status codes but not identical. Mapping them to your application's exception types at the data source layer means the rest of your code (use cases, notifiers, widgets) never deals with gRPC-specific errors directly. Your domain layer stays clean and framework-independent.</p>
<h3 id="heading-the-repository-layer">The Repository Layer</h3>
<pre><code class="language-dart">
abstract class BankingRepository {
  Future&lt;Result&lt;UserProfile, AppException&gt;&gt; getProfile(String userId);
  Stream&lt;BalanceResponse&gt; watchBalance(String userId);
  Stream&lt;Transaction&gt; streamTransactions(String userId);
  Future&lt;Result&lt;UploadResponse, AppException&gt;&gt; uploadDocument(
    List&lt;Uint8List&gt; chunks,
    String documentType,
  );
}


class BankingRepositoryImpl implements BankingRepository {
  final BankingRemoteDataSource _dataSource;

  BankingRepositoryImpl(this._dataSource);

  @override
  Future&lt;Result&lt;UserProfile, AppException&gt;&gt; getProfile(String userId) async {
    try {
      final profile = await _dataSource.getProfile(userId);
      return Result.success(profile);
    } on AppException catch (e) {
      return Result.failure(e);
    }
  }

  @override
  Stream&lt;BalanceResponse&gt; watchBalance(String userId) {
    return _dataSource.watchBalance(userId);
  }

  @override
  Stream&lt;Transaction&gt; streamTransactions(String userId) {
    return _dataSource.streamTransactions(userId);
  }

  @override
  Future&lt;Result&lt;UploadResponse, AppException&gt;&gt; uploadDocument(
    List&lt;Uint8List&gt; chunks,
    String documentType,
  ) async {
    try {
      final response = await _dataSource.uploadDocument(chunks, documentType);
      return Result.success(response);
    } on AppException catch (e) {
      return Result.failure(e);
    }
  }
}
</code></pre>
<h3 id="heading-the-riverpod-providers">The Riverpod Providers</h3>
<pre><code class="language-dart">
part 'banking_providers.g.dart';

@riverpod
Stream&lt;BalanceResponse&gt; balanceStream(BalanceStreamRef ref, String userId) {
  final repository = ref.watch(bankingRepositoryProvider);
  return repository.watchBalance(userId);
}

@riverpod
Stream&lt;Transaction&gt; transactionStream(
  TransactionStreamRef ref,
  String userId,
) {
  final repository = ref.watch(bankingRepositoryProvider);
  return repository.streamTransactions(userId);
}
</code></pre>
<p>With Riverpod, a provider that returns a <code>Stream</code> automatically becomes an <code>AsyncValue</code> that widgets can watch. Every new value pushed from the gRPC server stream triggers a widget rebuild automatically.</p>
<h3 id="heading-the-ui">The UI</h3>
<pre><code class="language-dart">// lib/features/banking/presentation/pages/dashboard_page.dart
class DashboardPage extends ConsumerWidget {
  final String userId;

  const DashboardPage({required this.userId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final balanceAsync = ref.watch(balanceStreamProvider(userId));
    final transactionsAsync = ref.watch(transactionStreamProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: Column(
        children: [
          balanceAsync.when(
            data: (balance) =&gt; BalanceCard(
              amount: balance.balance,
              currency: balance.currency,
            ),
            loading: () =&gt; const BalanceShimmer(),
            error: (e, _) =&gt; ErrorCard(message: e.toString()),
          ),

          const SizedBox(height: 24),

          Expanded(
            child: transactionsAsync.when(
              data: (transaction) =&gt; TransactionTile(
                transaction: transaction,
              ),
              loading: () =&gt; const TransactionShimmer(),
              error: (e, _) =&gt; ErrorCard(message: e.toString()),
            ),
          ),
        ],
      ),
    );
  }
}
</code></pre>
<p>Both the balance and transactions arrive through gRPC server streams. Both update in real-time as the server pushes new data. Both are handled identically through Riverpod's <code>AsyncValue</code> pattern. One framework with all patterns covered.</p>
<h2 id="heading-production-concerns">Production Concerns</h2>
<h3 id="heading-authentication-with-interceptors">Authentication with Interceptors</h3>
<p>For more granular authentication control, such as refreshing an expired token and retrying automatically, you implement a client interceptor:</p>
<pre><code class="language-dart">class AuthInterceptor extends ClientInterceptor {
  final TokenService _tokenService;

  AuthInterceptor(this._tokenService);

  @override
  ResponseFuture&lt;R&gt; interceptUnary&lt;Q, R&gt;(
    ClientMethod&lt;Q, R&gt; method,
    Q request,
    CallOptions options,
    ClientUnaryInvoker&lt;Q, R&gt; invoker,
  ) {
    final token = _tokenService.currentToken;
    final authenticatedOptions = options.mergedWith(
      CallOptions(metadata: {'authorization': 'Bearer $token'}),
    );
    return invoker(method, request, authenticatedOptions);
  }

  @override
  ResponseStream&lt;R&gt; interceptServerStreaming&lt;Q, R&gt;(
    ClientMethod&lt;Q, R&gt; method,
    Q request,
    CallOptions options,
    ClientServerStreamingInvoker&lt;Q, R&gt; invoker,
  ) {
    final token = _tokenService.currentToken;
    final authenticatedOptions = options.mergedWith(
      CallOptions(metadata: {'authorization': 'Bearer $token'}),
    );
    return invoker(method, request, authenticatedOptions);
  }
}
</code></pre>
<p>Pass the interceptor when creating the client:</p>
<pre><code class="language-dart">_client = BankingServiceClient(
  _channel,
  interceptors: [AuthInterceptor(tokenService)],
);
</code></pre>
<p>The interceptor fires on every call automatically. The data source code never touches auth logic directly.</p>
<h3 id="heading-error-handling-grpc-status-codes">Error Handling: gRPC Status Codes</h3>
<p>gRPC defines a standard set of status codes that every implementation follows:</p>
<table>
<thead>
<tr>
<th>Status Code</th>
<th>Meaning</th>
<th>Recommended Action</th>
</tr>
</thead>
<tbody><tr>
<td>OK (0)</td>
<td>Success</td>
<td>Use the response</td>
</tr>
<tr>
<td>CANCELLED (1)</td>
<td>Client cancelled the call</td>
<td>Ignore or log</td>
</tr>
<tr>
<td>UNKNOWN (2)</td>
<td>Unknown server error</td>
<td>Show generic error</td>
</tr>
<tr>
<td>INVALID_ARGUMENT (3)</td>
<td>Bad request data</td>
<td>Show validation error</td>
</tr>
<tr>
<td>DEADLINE_EXCEEDED (4)</td>
<td>Call timed out</td>
<td>Retry or show timeout message</td>
</tr>
<tr>
<td>NOT_FOUND (5)</td>
<td>Resource does not exist</td>
<td>Show not found UI</td>
</tr>
<tr>
<td>ALREADY_EXISTS (6)</td>
<td>Duplicate resource</td>
<td>Show conflict message</td>
</tr>
<tr>
<td>PERMISSION_DENIED (7)</td>
<td>Insufficient permissions</td>
<td>Show access denied</td>
</tr>
<tr>
<td>UNAUTHENTICATED (16)</td>
<td>Invalid or expired credentials</td>
<td>Navigate to login</td>
</tr>
<tr>
<td>RESOURCE_EXHAUSTED (8)</td>
<td>Rate limited</td>
<td>Back off and retry</td>
</tr>
<tr>
<td>UNAVAILABLE (14)</td>
<td>Server temporarily down</td>
<td>Show offline message</td>
</tr>
</tbody></table>
<pre><code class="language-dart">AppException _mapGrpcError(GrpcError error) {
  switch (error.code) {
    case StatusCode.unauthenticated:
      return AppException.unauthorized(message: 'Session expired');
    case StatusCode.permissionDenied:
      return AppException.forbidden(message: 'Access denied');
    case StatusCode.notFound:
      return AppException.notFound(message: error.message ?? 'Not found');
    case StatusCode.deadlineExceeded:
      return AppException.timeout(message: 'Request timed out');
    case StatusCode.unavailable:
      return AppException.serverUnavailable(message: 'Service unavailable');
    case StatusCode.resourceExhausted:
      return AppException.rateLimited(message: 'Too many requests');
    case StatusCode.invalidArgument:
      return AppException.validation(
        message: error.message ?? 'Invalid input',
      );
    default:
      return AppException.unknown(
        message: error.message ?? 'An error occurred',
      );
  }
}
</code></pre>
<h3 id="heading-deadlines-and-timeouts">Deadlines and Timeouts</h3>
<p>Every gRPC call should have a deadline. Without deadlines, a slow server can make your app hang indefinitely.</p>
<p>Per-call deadline:</p>
<pre><code class="language-dart">Future&lt;UserProfile&gt; getProfile(String userId) async {
  return await _client.getProfile(
    ProfileRequest(userId: userId),
    options: CallOptions(timeout: const Duration(seconds: 10)),
  );
}
</code></pre>
<p>Default deadline for all calls:</p>
<pre><code class="language-dart">_client = BankingServiceClient(
  _channel,
  options: CallOptions(
    timeout: const Duration(seconds: 30),
    metadata: {'authorization': 'Bearer $authToken'},
  ),
);
</code></pre>
<p>When the deadline is exceeded, the call throws a <code>GrpcError</code> with <code>StatusCode.deadlineExceeded</code>, which your error mapper handles appropriately.</p>
<h3 id="heading-logging-interceptor">Logging Interceptor</h3>
<pre><code class="language-dart">class LoggingInterceptor extends ClientInterceptor {
  @override
  ResponseFuture&lt;R&gt; interceptUnary&lt;Q, R&gt;(
    ClientMethod&lt;Q, R&gt; method,
    Q request,
    CallOptions options,
    ClientUnaryInvoker&lt;Q, R&gt; invoker,
  ) {
    final stopwatch = Stopwatch()..start();
    debugPrint('[gRPC] --&gt; ${method.path}');

    final response = invoker(method, request, options);

    response.then((_) {
      stopwatch.stop();
      debugPrint(
        '[gRPC] &lt;-- ${method.path} (${stopwatch.elapsedMilliseconds}ms)',
      );
    }).catchError((error) {
      stopwatch.stop();
      debugPrint(
        '[gRPC] ERROR ${method.path}: $error (${stopwatch.elapsedMilliseconds}ms)',
      );
    });

    return response;
  }
}
</code></pre>
<h2 id="heading-grpc-vs-rest-vs-websockets-when-to-use-what">gRPC vs REST vs WebSockets: When to Use What</h2>
<h3 id="heading-when-to-use-rest">When to Use REST</h3>
<p>The API is consumed by third-party developers or external partners. JSON over HTTP is the universal language that every developer in every language can access immediately without learning new tooling.</p>
<p>The operation is simple request-response with no streaming requirements and only one client platform. REST is simpler to implement, simpler to debug, and simpler to test for straightforward CRUD operations.</p>
<p>Public documentation and human readability matter. REST with OpenAPI/Swagger gives you browsable, testable documentation that developers can explore in a browser.</p>
<p>Caching is important. REST GET responses can be cached at every layer: CDN, reverse proxy, browser cache. gRPC requests can't leverage standard HTTP caching.</p>
<h3 id="heading-use-websockets-when">Use WebSockets When</h3>
<p>You need true bidirectional real-time communication and gRPC isn't already in your stack. Chat applications, multiplayer games, and collaborative tools where both sides need to speak freely are natural WebSocket use cases.</p>
<p>Browser support without a proxy layer is required. WebSockets work natively in every modern browser. gRPC in the browser requires gRPC-Web and a proxy layer.</p>
<h3 id="heading-when-to-use-grpc">When to Use gRPC</h3>
<p>Multiple platform teams share the same service contract. When Flutter, React, Go, and Python services all call the same backend, a <code>.proto</code> file enforced by the compiler prevents contract drift across every team.</p>
<p>Large payloads are called by many internal applications. The more fields in the payload and the more applications consuming it, the stronger the case for protobuf's binary encoding and generated clients.</p>
<p>Network conditions are variable and payload size matters. Users on 2G or 3G connections benefit directly from protobuf's compact binary format. The same data in protobuf can be 3 to 10 times smaller than JSON, translating to faster load times and lower data consumption for users on limited data plans.</p>
<p>Real-time streaming is required and you want one unified framework for all communication patterns. gRPC's four patterns cover every scenario without requiring a separate WebSocket server alongside your API.</p>
<p>Service-to-service communication at high frequency is involved. Two internal services calling each other thousands of times per second over a persistent multiplexed HTTP/2 connection with binary protobuf encoding will significantly outperform REST with JSON over HTTP/1.1.</p>
<h2 id="heading-the-hybrid-architecture">The Hybrid Architecture</h2>
<p>The mature engineering decision is not choosing gRPC over REST or REST over gRPC. It's knowing where each belongs and using both deliberately.</p>
<p>Most organizations of meaningful scale end up with a hybrid:</p>
<p>The public REST API serves external consumers who need simplicity and JSON. The internal gRPC network handles high-frequency, high-performance service-to-service calls. The mobile gRPC endpoints give Flutter clients real-time capabilities over efficient binary connections.</p>
<p>Each layer uses the right tool for its specific requirements. No ideological commitment to one protocol. Pure engineering pragmatism.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Remote Procedure Calls began with a simple observation: network communication shouldn't require developers to think about network communication. Calling a function on another machine should feel like calling a function on your own.</p>
<p>Protocol Buffers took this further by solving the data problem. JSON is readable but verbose. Binary encoding with a compiler-enforced schema produces payloads that are smaller, faster to parse, and guaranteed to match the contract every team agreed on. For users on slow networks and internal systems processing millions of requests daily, this efficiency is a business advantage.</p>
<p>gRPC combined RPC semantics, Protocol Buffer encoding, and HTTP/2 transport into a framework that supports four distinct communication patterns: unary request-response, server streaming, client streaming, and bidirectional streaming. All from the same generated client, using the same persistent connection, and enforced by the same <code>.proto</code> contract.</p>
<p>The organizational practice of a shared protobuf repository transforms gRPC from a technical tool into an engineering discipline. Contract changes go through review. Breaking changes are caught by the compiler. Every team generates their own strongly typed client from the same source of truth and stays in sync automatically, regardless of programming language.</p>
<p>In Flutter specifically, gRPC server streams integrate naturally with Dart's <code>Stream</code> type and Riverpod's stream providers. Real-time balance updates and live transaction feeds that would require polling with REST or a separate WebSocket implementation become simple stream subscriptions. The server pushes, the widget listens, and nothing else is required.</p>
<p>The decision of when to use gRPC versus REST versus WebSockets isn't about preference. It's about matching the tool to the requirement. Public APIs belong behind REST. High-frequency internal service communication belongs on gRPC. Large payloads consumed by many internal systems belong in protobuf. Real-time bidirectional features belong on gRPC streaming. Users on variable networks deserve the smallest payloads you can give them.</p>
<p>Understanding all of these tools, understanding why they exist, and knowing when to reach for each one is what separates engineers who use tools from engineers who think in systems.</p>
<p>Happy Coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What Is HyDE? How to Improve RAG with Hypothetical Documents ]]>
                </title>
                <description>
                    <![CDATA[ Retrieval-Augmented Generation, commonly known as RAG, has become one of the most widely used approaches for building applications with large language models. Instead of asking an LLM to answer entire ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-is-hyde-how-to-improve-rag-with-hypothetical-documents/</link>
                <guid isPermaLink="false">6a6136e1ca77a68a9bf2d904</guid>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Sameer Shukla ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 21:32:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/71e96334-b1b2-42db-9f0d-d0d9552acb44.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Retrieval-Augmented Generation, commonly known as RAG, has become one of the most widely used approaches for building applications with large language models.</p>
<p>Instead of asking an LLM to answer entirely from its training data, a RAG system retrieves relevant information from an external knowledge base and provides that information to the model as context.</p>
<p>The basic idea is straightforward:</p>
<ul>
<li><p>Convert the user’s question into an embedding.</p>
</li>
<li><p>Search a vector database for semantically similar document chunks.</p>
</li>
<li><p>Pass the retrieved chunks to an LLM.</p>
</li>
<li><p>Generate an answer grounded in those chunks.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/64b2c122c21d916a1b725c11/8cda4575-53dc-4531-8f7b-6c930bd743e4.png" alt=" Figure1:  Retrieval-Augmented Generation (RAG) workflow " style="display:block;margin-left:auto" width="2074" height="3954" loading="lazy">

<p>But this apparently simple process has a major weakness: the user’s question and the document containing the answer may be written very differently.</p>
<p>A user might ask:</p>
<blockquote>
<p>Why does my AWS Glue job become significantly slower after processing several million records?</p>
</blockquote>
<p>The relevant document in the knowledge base might say:</p>
<blockquote>
<p>Performance degradation can occur when Spark executors experience excessive shuffle operations, skewed partitions, memory pressure, or repeated spilling to disk.</p>
</blockquote>
<p>The query and the document discuss the same problem, but they use different vocabulary, structure, and levels of detail. A direct query embedding may therefore fail to place them close enough in the embedding space.</p>
<p>This is the problem that Hypothetical Document Embeddings, or HyDE, was designed to solve.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-hyde">What is HyDE?</a></p>
</li>
<li><p><a href="#heading-the-mechanics-of-hyde">The Mechanics of HyDE</a></p>
</li>
<li><p><a href="#heading-minimal-implementation">Minimal Implementation</a></p>
</li>
<li><p><a href="#heading-why-hallucination-doesnt-automatically-break-hyde">Why Hallucination Doesn't Automatically Break HyDE</a></p>
</li>
<li><p><a href="#heading-production-guardrails">Production Guardrails</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>To get the most out of this article, there are a few things you should know and have.</p>
<p>What you need to know:</p>
<ul>
<li><p>Basic familiarity with <a href="https://www.freecodecamp.org/news/rag-explained-simply-with-a-real-project/">RAG and why it's used</a>.</p>
</li>
<li><p>How vector embeddings work, at a conceptual level.</p>
</li>
<li><p>Working knowledge of Python.</p>
</li>
</ul>
<p>What you need to have:</p>
<ul>
<li><p>A local Python environment with numpy, sentence-transformers, and Anthropic installed</p>
</li>
<li><p>An Anthropic API key if you want to run the HyDE code sample (available at <a href="http://console.anthropic.com">console.anthropic.com</a>)</p>
</li>
</ul>
<h2 id="heading-what-is-hyde"><strong>What is HyDE?</strong></h2>
<p>HyDE stands for Hypothetical Document Embeddings. The technique is simple. At query time, you prompt an LLM to generate a hypothetical document that would answer the user's question, embed that document instead of the query, and use its vector to search your index. That's the whole idea. Everything else is engineering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64b2c122c21d916a1b725c11/50c5909c-c7fa-4c92-bf11-c7a1b3411142.png" alt="50c5909c-c7fa-4c92-bf11-c7a1b3411142" style="display:block;margin-left:auto" width="2272" height="4584" loading="lazy">

<p>Figure 2: The HyDE process</p>
<p>The hypothetical document isn't treated as the final answer. It's used only as a bridge between the user’s query and the real documents stored in the knowledge base.</p>
<p>This distinction is critical.</p>
<p>The generated document may contain incorrect details. That's not necessarily a failure, because the system doesn't present it directly to the user. Its purpose is to produce a richer semantic representation of the information being sought.</p>
<p>The original HyDE approach used a language model to generate hypothetical documents and an unsupervised dense retriever to map those documents into an embedding space. The embedding acts as a search instruction for retrieving real documents from the corpus.</p>
<h3 id="heading-why-hyde-works">Why HyDE Works</h3>
<p>The intuition is geometric. A dense retriever projects text into a semantic space, and similarity between two pieces of text is the cosine of the angle between their vectors.</p>
<p>When you embed a question and compare it to a passage, you're measuring an angle between two shapes of text that were never meant to be close. Your embedding model was trained to place semantically similar text near each other, but it wasn't trained to place a question near its answer. Those are different geometries.</p>
<p>HyDE closes that gap by making both sides of the comparison the same shape. The hypothetical passage sits in the same neighborhood of the vector space as real documentation, because it was written in the same register, with the same vocabulary, at the same level of detail. The vector search is now comparing answers to answers rather than questions to answers, and the similarity signal is cleaner.</p>
<p>That's the entire mechanism. Everything else – the prompt engineering, model selection, and caching – is downstream of this one geometric fact.</p>
<h2 id="heading-the-mechanics-of-hyde"><strong>The Mechanics of HyDE</strong></h2>
<p>First, let's say that the user asks: why does my Lambda function take longer to respond when it hasn't been called in a while?</p>
<p>Then you ask the LLM that question in a short prompt: "Write a passage from technical documentation that answers this question."</p>
<p>The LLM responds with something like:</p>
<blockquote>
<p>"AWS Lambda will reclaim execution environments that have been idle for some time. When the function is invoked again, a cold start occurs, which involves setting up the runtime and loading dependencies. This adds additional latency for the first invocation following an idle period."</p>
</blockquote>
<p>Now you embed that generated passage. Not the original question –&nbsp;the passage.</p>
<p>You use that embedding to search your vector store. The hypothetical passage was formatted like a real doc, so now the real AWS docs on cold starts are near each other in the vector space.</p>
<p>Next, you take the top k retrieved documents and pass them to the generator, along with the original user question. The generator answers using the real docs it retrieved. The hypothetical is discarded.</p>
<p>The LLM was used twice, but for different jobs: once to rewrite the query as a document, and again to answer the question using retrieved documents. The first call is cheap and low stakes. The second is the one that matters.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64b2c122c21d916a1b725c11/b8fb1260-392c-4248-bcea-1328813dfe7d.png" alt="Figure3:  Comparison of Naive RAG and HyDE pipelines. " style="display:block;margin:0 auto" width="2664" height="2344" loading="lazy">

<p>Figure3: &nbsp;Comparison of Naïve RAG and HyDE pipelines.</p>
<h2 id="heading-minimal-implementation">Minimal Implementation</h2>
<p>The naïve RAG may look like this:</p>
<pre><code class="language-python">import numpy as np
from sentence_transformers import SentenceTransformer

collection = [
    "AWS Lambda reclaims idle execution environments after a period of inactivity, causing a cold start on the next invocation that includes runtime bootstrap and dependency loading.",
    "Apache Airflow schedules tasks using a directed acyclic graph, where each node represents a unit of work.",
    "AWS Glue crawlers infer schemas from source data and populate the Glue Data Catalog automatically.",
    "Amazon Bedrock exposes foundation models behind a single API and handles provisioning transparently.",
    "DynamoDB partitions data across nodes using the partition key, which determines physical placement.",
]

embedder = SentenceTransformer("all-MiniLM-L6-v2")
collection_embeddings = embedder.encode(collection, normalize_embeddings=True)

def retrieve(query: str, k: int = 2) -&gt; list[str]:
    query_embedding = embedder.encode(query, normalize_embeddings=True)
    scores = collection_embeddings @ query_embedding
    top_k = np.argsort(scores)[::-1][:k]
    return [collection[i] for i in top_k]

query = "Why does my Lambda function take longer to respond when it hasn't been called in a while?"
for passage in retrieve(query):
    print(passage)
</code></pre>
<p>On this sample collection, it will likely return the right passage at rank 1. Scale to fifty thousand documents with real query variance, and the correct passage starts sliding down the ranking.</p>
<p>The line to notice, for what comes next, is the one inside retrieve where <code>embedder.encode(query, ...)</code> runs. That's where the raw question becomes a vector, and this is the line HyDE changes.</p>
<p>In the HyDE variant, the delta is one function:</p>
<pre><code class="language-python">import numpy as np
from anthropic import Anthropic
from sentence_transformers import SentenceTransformer

# collection. In production this is your vector store.

collection = [
    "AWS Lambda reclaims idle execution environments after a period of inactivity, causing a cold start on the next invocation that includes runtime bootstrap and dependency loading.",
    "Apache Airflow schedules tasks using a directed acyclic graph, where each node represents a unit of work.",
    "AWS Glue crawlers infer schemas from source data and populate the Glue Data Catalog automatically.",
    "Amazon Bedrock exposes foundation models behind a single API and handles provisioning transparently.",
    "DynamoDB partitions data across nodes using the partition key, which determines physical placement.",
]

embedder = SentenceTransformer("all-MiniLM-L6-v2")
corpus_embeddings = embedder.encode(collection, normalize_embeddings=True)

client = Anthropic()

# HyDE: generate a hypothetical answer, embed that, then search.

HYDE_PROMPT = (
    "Write a short passage from technical documentation that would answer "
    "the following question. Write in the register of official docs: "
    "declarative, precise, no hedging. Do not include the question itself. "
    "Passage only, two to four sentences.\n\n"
    "Question: {query}"
)

def generate_hypothetical(query: str) -&gt; str:
    """Ask an LLM to write a fake documentation passage answering the query."""
    message = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=200,
        messages=[
            {"role": "user", "content": HYDE_PROMPT.format(query=query)}
        ],
    )
    return message.content[0].text

def retrieve_hyde(query: str, k: int = 2) -&gt; list[str]:
    """Generate a hypothetical passage, embed it, and search with that vector."""
    hypothetical = generate_hypothetical(query)
    hyde_embedding = embedder.encode(hypothetical, normalize_embeddings=True)
    scores = corpus_embeddings @ hyde_embedding
    top_k_indices = np.argsort(scores)[::-1][:k]
    return [collection[i] for i in top_k_indices]

if __name__ == "__main__":
    query = (
        "Why does my Lambda function take longer to respond "
        "when it hasn't been called in a while?"
    )
    for passage in retrieve_hyde(query):
        print(passage)
</code></pre>
<p>That's the whole technique. There's one extra LLM call, one extra function, and everything else is identical to the baseline. The hypothetical text is thrown away after embedding and never reaches the generator.</p>
<p>The naïve baseline vectorizes the question directly and performs the cosine similarity search on the collection vectors. It's precisely this one-line code, which invokes <code>embedder.encode(query, ...)</code>, where the question is vectorized into a vector of question shape rather than an answer vector shape, and it's the sole cause of the retrieval quality issue discussed in this article.</p>
<p>The difference in the HyDE approach is made in one thing only. Before the embedding takes place, an LLM is asked to generate a small piece of text in the register of technical documentation answering the question, and the vector is computed for this text rather than for the original question. Everything else remains exactly the same – the same embedding model, cosine similarity search, and top-k selections are used.</p>
<p>This hypothetical passage is never used for anything other than for generating the search vector. The difference isn't made by any difference in the retrieval method but only by changing the shape of the text to compare.</p>
<h2 id="heading-why-hallucination-doesnt-automatically-break-hyde">Why Hallucination Doesn't Automatically Break HyDE</h2>
<p>At first, HyDE appears contradictory. Why would a system improve factual retrieval by asking a language model to generate information before retrieving the facts?</p>
<p>The answer is that HyDE uses the generated document as a retrieval representation, not as trusted knowledge.</p>
<p>Suppose the user asks: What caused the database outage on July 18? The LLM can't know the actual cause from a private incident report. It has to make something up.</p>
<p>So it might say something like,</p>
<blockquote>
<p>"The July 18 database outage was caused by a misconfiguration of the failover on the primary replica, which caused cascading connection timeouts in the dependent services. Engineers restored service by rerouting traffic to the secondary region and rebuilding the connection pool."</p>
</blockquote>
<p>That passage is a complete fabrication. The real cause might have been a disk failure, a bad deploy, a certificate expiry, anything. But look at what the passage contains: words like outage, failover, replica, cascading timeout, connection pool, secondary region. Those are the exact words that will appear in your real incident postmortem, whatever the actual cause was.</p>
<p>Postmortems for database outages sound like postmortems for database outages. They share vocabulary, register, and structure regardless of the specific root cause.</p>
<p>The LLM's generated passage might also touch on connection saturation, lock contention, storage latency, failed deployment, or resource exhaustion. Some of those details may be wrong, but it doesn't matter. Each of those terms still pulls the embedding toward the same neighborhood as real outage analyses, root cause reports, database metrics, and postmortem documents.</p>
<p>When you embed that fabricated passage, the vector lands in the neighborhood where your real postmortem lives. The vector search retrieves the correct postmortem. Only then does the generator read the actual document and produce the true answer.</p>
<p>The hypothetical was wrong about the facts, but it was right about the shape. Shape is what the embedding sees. Facts are what the retrieved document provides.</p>
<p>The real risk here isn't the hallucination itself but what you do with it. If the system mistakenly passes the hypothetical document to the final answer generator as though it were retrieved evidence, the fabrication reaches the user.</p>
<p>The mitigation is architectural, not statistical: keep the hypothetical strictly inside the retrieval step and never let it leak into the generation context. The next section covers this in detail.</p>
<h2 id="heading-production-guardrails">Production Guardrails</h2>
<p>HyDE adds an LLM to the retrieval path, which introduces new engineering concerns. Here are some production guardrails you can add that'll make things safer and more reliable:</p>
<h3 id="heading-apply-timeouts-and-fallbacks">Apply Timeouts and Fallbacks</h3>
<p>If hypothetical generation is slow or fails, degrade to naïve retrieval instead of blocking the user.</p>
<pre><code class="language-python">def retrieve_with_fallback(query: str, k: int = 2) -&gt; list[str]:
    try:
        hypothetical = generate_hypothetical(query)
        search_vector = embedder.encode(hypothetical, normalize_embeddings=True)
    except Exception:
        logger.exception(
            "HyDE generation failed; falling back to the original query."
        ) 
        # Fall back to embedding the raw query
        search_vector = embedder.encode(query, normalize_embeddings=True)

    scores = corpus_embeddings @ search_vector
    top_k = np.argsort(scores)[::-1][:k]
    return [collection[i] for i in top_k]
</code></pre>
<p>Set an explicit timeout on the client itself [Anthropic(timeout=3.0)]</p>
<h3 id="heading-limit-generation-length">Limit Generation Length</h3>
<p>Long hypothetical documents introduce unrelated concepts and dilute the embedding. Cap the output at the LLM call.</p>
<pre><code class="language-python">message = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=200,   # keep the hypothetical dense
    messages=[{"role": "user", "content": HYDE_PROMPT.format(query=query)}],
)
</code></pre>
<p>200 tokens should be sufficient for a targeted piece of text in the domain of technical documentation. Anything beyond that typically makes retrieval harder.</p>
<h3 id="heading-protect-sensitive-data-before-sending-to-an-external-model-provider">Protect Sensitive Data Before Sending to an External Model Provider</h3>
<p>Strip personal identification data from the input before running the hypothesis generation, and enforce it at the interface level instead of relying on downstream callers.</p>
<pre><code class="language-python">PII_PATTERNS = {
    "email": r'\b[\w.-]+@[\w.-]+\.\w+\b',
    "ssn":   r'\b\d{3}-\d{2}-\d{4}\b',
    "card":  r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b',
}

def scrub_pii(text: str) -&gt; str:
    for label, pattern in PII_PATTERNS.items():
        text = re.sub(pattern, f"[REDACTED_{label.upper()}]", text)
    return text

def safe_generate_hypothetical(query: str) -&gt; str:
    return generate_hypothetical(scrub_pii(query))
</code></pre>
<p>This will be the lowest requirement for regulated data. Add more controls above it.</p>
<h3 id="heading-trace-every-stage">Trace Every Stage</h3>
<p>Without visibility at every stage, there's no way to debug retrieval problems. Collect the query, prompt, hypothetical response, delays, IDs retrieved, and similarity scores for all queries.</p>
<pre><code class="language-python">import time
import logging

logger = logging.getLogger(__name__)

def traced_retrieve_hyde(query: str, k: int = 2) -&gt; HyDEContext:
    t0 = time.time()
    hypothetical = generate_hypothetical(query)
    gen_ms = int((time.time() - t0) * 1000)

    t1 = time.time()
    search_vector = embedder.encode(hypothetical, normalize_embeddings=True)
    embed_ms = int((time.time() - t1) * 1000)

    scores = corpus_embeddings @ search_vector
    top_k = np.argsort(scores)[::-1][:k]

    logger.info(
        "hyde_retrieval",
        extra={
            "query": query,
            "prompt_version": "v1",
            "hypothetical": hypothetical,
            "gen_latency_ms": gen_ms,
            "embed_latency_ms": embed_ms,
            "retrieved_ids": top_k.tolist(),
            "similarity_scores": [float(scores[i]) for i in top_k],
        },
    )
    return HyDEContext(
        original_query=query,
        hypothetical=hypothetical,
        retrieved_documents=[collection[i] for i in top_k],
    )
</code></pre>
<p>The structured log forms the basis for latency dashboards, drift alerts, and offline retrieval evaluations.</p>
<h3 id="heading-when-to-use-hyde-and-when-not-to">When to Use HyDE, and When Not to</h3>
<p>Use HyDE when:</p>
<ul>
<li><p>Your embedding model fails to fully grasp your domain.</p>
</li>
<li><p>You don’t have labeled query-document pairs to fine-tune a retriever.</p>
</li>
<li><p>Users ask conversational questions, but your documents are formal or technical.</p>
</li>
<li><p>You can afford an extra LLM call before retrieval.</p>
</li>
</ul>
<p>Avoid HyDE if:</p>
<ul>
<li><p>Your application has strict latency requirements.</p>
</li>
<li><p>A general-purpose LLM may generate the wrong domain terminology.</p>
</li>
<li><p>Your queries already contain strong keywords, identifiers, or error codes.</p>
</li>
<li><p>BM25 or hybrid search already retrieves relevant results.</p>
</li>
<li><p>You have enough labeled data to fine-tune the retriever directly.</p>
</li>
</ul>
<h2 id="heading-summary">Summary</h2>
<p>HyDE is a small idea with a large effect. You're not changing your index, embedding model, or generator. You're changing one line: what gets embedded when a query arrives. That single change reshapes the geometry of the search from question against answer to answer against answer, and retrieval quality follows.</p>
<p>The technique isn't magic. It trades latency and cost for recall, and it earns its keep only when the query document asymmetry is the actual bottleneck in your pipeline. When it is, HyDE is one of the cheapest wins in the RAG toolbox.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Make a Static HTML Page Editable in the Browser with Vanilla JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ When you maintain a document for someone else, such as a résumé, a one-page portfolio, or a printable menu, the bottleneck is rarely the layout. It's the edit loop. Every small change ("move this bull ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-make-a-static-html-page-editable-in-the-browser-with-vanilla-javascript/</link>
                <guid isPermaLink="false">6a61266b62f25d8178b980e0</guid>
                
                    <category>
                        <![CDATA[ HTML ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CSS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ DOM ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ timothy ogbemudia ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 20:22:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/08400ed2-ef6a-404f-a135-b6cefe9d6919.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you maintain a document for someone else, such as a résumé, a one-page portfolio, or a printable menu, the bottleneck is rarely the layout. It's the edit loop.</p>
<p>Every small change ("move this bullet up", "delete that line", "this link is dead") goes through you, the person with the code editor, even though the person requesting the change knows exactly what they want.</p>
<p>I ran into this maintaining a family member's résumé as a single static HTML file. The design was done. The content was theirs. But every revision, whether it was reordering a role, adding a certification, fixing a link, or nudging a print page break, meant another round of "send me the change, I'll edit the file." After the tenth round, the fix became obvious: make the page edit itself.</p>
<p>In this article, you'll build an in-browser editing layer for a static HTML page using <code>contenteditable</code>, about a hundred lines of vanilla JavaScript, and no build step. The person editing can change any text, reorder or delete any block, add new content, edit links, control print pagination, and print to PDF. A refresh restores the original file, untouched.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-what-is-contenteditable">What Iscontenteditable?</a></p>
</li>
<li><p><a href="#heading-why-not-a-react-app">Why Not a React App?</a></p>
</li>
<li><p><a href="#heading-how-to-prepare-the-markup-for-reordering">How to Prepare the Markup for Reordering</a></p>
</li>
<li><p><a href="#heading-how-to-attach-controls-to-every-block">How to Attach Controls to Every Block</a></p>
</li>
<li><p><a href="#heading-how-to-show-controls-only-on-the-innermost-block">How to Show Controls Only on the Innermost Block</a></p>
</li>
<li><p><a href="#heading-how-to-move-and-delete-blocks">How to Move and Delete Blocks</a></p>
</li>
<li><p><a href="#heading-how-to-edit-links-inside-contenteditable">How to Edit Links Insidecontenteditable</a></p>
</li>
<li><p><a href="#heading-how-to-let-users-control-print-pagination">How to Let Users Control Print Pagination</a></p>
</li>
<li><p><a href="#heading-how-to-add-new-content-from-templates">How to Add New Content from Templates</a></p>
</li>
<li><p><a href="#heading-why-nothing-persists">Why Nothing Persists</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have:</p>
<ul>
<li><p>Working knowledge of HTML and CSS, including CSS Grid and <code>@media print</code></p>
</li>
<li><p>Basic understanding of JavaScript DOM APIs (<code>querySelector</code>, event listeners, creating elements)</p>
</li>
<li><p>No frameworks, libraries, or build tools. That's the point.</p>
</li>
</ul>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<ul>
<li><p>What <code>contenteditable</code> gives you for free, and where it stops</p>
</li>
<li><p>How to attach move/delete controls to repeatable blocks with one reusable function</p>
</li>
<li><p>How to show controls only on the innermost hovered block using <code>:has()</code></p>
</li>
<li><p>How to reorder DOM nodes without a framework, and the markup prep that makes it safe</p>
</li>
<li><p>How to edit link URLs inside an editable region</p>
</li>
<li><p>How to let users place print page breaks themselves</p>
</li>
<li><p>How to add new content from templates, with placeholder text pre-selected</p>
</li>
<li><p>Why "nothing persists" can be a feature, not a limitation</p>
</li>
</ul>
<h2 id="heading-what-is-contenteditable">What Is <code>contenteditable</code>?</h2>
<p><code>contenteditable</code> is an HTML attribute that turns any element into an editable region. The browser handles the hard parts: caret placement, text selection, typing, deletion, clipboard, and undo history.</p>
<pre><code class="language-html">&lt;div class="page" contenteditable="true" spellcheck="false"&gt;
  &lt;!-- the entire document --&gt;
&lt;/div&gt;
</code></pre>
<p>That one attribute gets you further than you might expect. Clicking any paragraph places a caret. Cmd+Z undoes typing. Pressing Enter inside a <code>&lt;ul&gt;</code> creates a new <code>&lt;li&gt;</code>. The browser understands list semantics natively, so "add a bullet by pressing Enter" works with zero code.</p>
<p>But <code>contenteditable</code> alone isn't an editor. It has no concept of <em>blocks</em>. It won't move a job entry above another one, delete a card cleanly, or change an <code>href</code>. Clicking a link inside an editable region just places the caret in its text. Everything structural is on you. The rest of this article is about filling that gap.</p>
<h2 id="heading-why-not-a-react-app">Why Not a React App?</h2>
<p>The obvious alternative is to rebuild the page as a "real" app: components, state, a form per section, and an export button. I decided against it, and the tradeoff is important.</p>
<p>The file lives in a <code>public/</code> folder and is served as a static asset. It works from a URL, from disk, or from an email attachment. It has no dependencies to install, no build to run, and no way to rot when a toolchain updates.</p>
<p>The editing needs are small and bounded: change text, move blocks, delete blocks, add blocks, and print. That's DOM manipulation, the thing the DOM API is already good at.</p>
<p>A framework earns its complexity when state outlives the DOM: persistence, collaboration, validation, and syncing. This page has none of those requirements. When your state <em>is</em> the DOM and the lifetime is one session, a framework is an extra layer that buys you nothing.</p>
<h2 id="heading-how-to-prepare-the-markup-for-reordering">How to Prepare the Markup for Reordering</h2>
<p>Before writing any JavaScript, look at your markup for anything positional, meaning elements that only make sense <em>between</em> other elements. In my case, jobs were separated by <code>&lt;hr&gt;</code> dividers:</p>
<pre><code class="language-html">&lt;div class="job"&gt;...&lt;/div&gt;
&lt;hr class="divider"&gt;
&lt;div class="job"&gt;...&lt;/div&gt;
</code></pre>
<p>The moment blocks can move or be deleted, separators like this become landmines. Delete a job and its divider survives as an orphaned line. Move a job and the divider stays behind.</p>
<p>The fix is to delete the <code>&lt;hr&gt;</code> elements entirely and derive the separator from adjacency:</p>
<pre><code class="language-css">.section .job + .job {
  border-top: 0.5px solid var(--rule);
  padding-top: 18px;
}
</code></pre>
<p>The sibling combinator draws a rule above every job that follows another job. Reorder them, delete them, add new ones, and the separators are always exactly where they should be, because they're computed from structure rather than stored in it. This is the same principle as deriving state instead of duplicating it, applied to CSS.</p>
<h2 id="heading-how-to-attach-controls-to-every-block">How to Attach Controls to Every Block</h2>
<p>Each movable block gets a small control cluster with move up, move down, and delete actions, injected by one reusable function:</p>
<pre><code class="language-js">const SELECTORS = ['.section', '.job', '.bullets li', '.cert-card', '.skills-row', '.edu-row'];
const BREAKABLE = new Set(['.section', '.job']);

function makeBlock(el, sel) {
  el.classList.add('blk');
  el.dataset.sel = sel;
  const ctl = document.createElement('span');
  ctl.className = 'ctl';
  ctl.setAttribute('contenteditable', 'false');
  ctl.innerHTML =
    '&lt;button data-act="up" data-tip="Move this up"&gt;↑&lt;/button&gt;' +
    '&lt;button data-act="down" data-tip="Move this down"&gt;↓&lt;/button&gt;' +
    (BREAKABLE.has(sel) ? '&lt;button data-act="brk" data-tip="Page break: start a new printed page here"&gt;⇟&lt;/button&gt;' : '') +
    '&lt;button data-act="del" data-tip="Remove this. Refresh to bring it back"&gt;×&lt;/button&gt;';
  el.appendChild(ctl);
}

SELECTORS.forEach(sel =&gt; {
  page.querySelectorAll(sel).forEach(el =&gt; makeBlock(el, sel));
});
</code></pre>
<p>A few design decisions are worth calling out.</p>
<p>First, <code>contenteditable="false"</code> on the control cluster. Editable regions inherit. Everything inside the page is editable unless you opt out. Without this, the user could place a caret inside your buttons and delete them like text.</p>
<p>Second, <code>el.dataset.sel</code> records <em>which selector matched</em>. This matters later: when a block moves, it should only swap with siblings of its own kind. A bullet moves among bullets, a job among jobs. Storing the selector on the element makes that check trivial.</p>
<p>Third, the controls live <em>inside</em> the block they control. That gives you positioning for free, with <code>position: absolute</code> against the block's own <code>position: relative</code>, and means a block carries its controls with it wherever it moves.</p>
<h2 id="heading-how-to-show-controls-only-on-the-innermost-block">How to Show Controls Only on the Innermost Block</h2>
<p>Blocks nest: a bullet sits inside a job, which sits inside a section. Hovering a bullet technically hovers all three, and naïve CSS shows three control clusters at once. The result is visual noise exactly where the user is trying to focus.</p>
<p>Modern CSS solves this in one line:</p>
<pre><code class="language-css">.blk:hover:not(:has(.blk:hover)) &gt; .ctl { display: inline-flex; }
</code></pre>
<p>Read it inside out: show a block's controls when it's hovered, <em>unless</em> some descendant block is also hovered, in which case that deeper block wins. Hover a bullet, you get bullet controls. Hover the job's title (outside any bullet), you get job controls. One rule, no JavaScript.</p>
<p><code>:has()</code> is supported in every current browser, but a fallback costs one more rule:</p>
<pre><code class="language-css">@supports not selector(:has(*)) {
  .blk:hover &gt; .ctl { display: inline-flex; }
}
</code></pre>
<p>Older browsers get the noisier all-ancestors behavior instead of no controls at all. Degrade loudly, not silently.</p>
<h2 id="heading-how-to-move-and-delete-blocks">How to Move and Delete Blocks</h2>
<p>With controls attached, the actual reordering is short. One delegated listener handles every button on the page:</p>
<pre><code class="language-js">function siblings(el) {
  return [...el.parentElement.children].filter(c =&gt;
    c.classList.contains('blk') &amp;&amp; c.dataset.sel === el.dataset.sel);
}

page.addEventListener('click', e =&gt; {
  const btn = e.target.closest('.ctl button');
  if (!btn) return;
  e.preventDefault();
  const el = btn.closest('.blk');
  const sibs = siblings(el);
  const i = sibs.indexOf(el);
  const act = btn.dataset.act;
  if (act === 'up' &amp;&amp; i &gt; 0) sibs[i - 1].before(el);
  else if (act === 'down' &amp;&amp; i &lt; sibs.length - 1) sibs[i + 1].after(el);
  else if (act === 'del') el.remove();
  else if (act === 'brk') el.classList.toggle('page-break');
});
</code></pre>
<p><code>siblings()</code> is where <code>dataset.sel</code> pays off: it filters the parent's children down to blocks <em>of the same kind</em>, so a job can never swap into the middle of a bullet list. <code>before()</code> and <code>after()</code> move the live node with no cloning or re-rendering, and the block's own controls travel with it.</p>
<p>There's one subtle bug to prevent. Clicking a button inside an editable region moves the text caret first, which can scroll the page or collapse a selection. Suppress it at <code>mousedown</code>, before the browser acts:</p>
<pre><code class="language-js">page.addEventListener('mousedown', e =&gt; {
  if (e.target.closest('.ctl, .add-btn')) e.preventDefault();
});
</code></pre>
<p>Forgetting this is the kind of thing you only notice as a vague feeling that clicking buttons "jumps." It's worth ruling out before it ships.</p>
<h2 id="heading-how-to-edit-links-inside-contenteditable">How to Edit Links Inside <code>contenteditable</code></h2>
<p>Inside an editable region, single-clicking a link places the caret instead of navigating. That's correct for text editing but leaves no way to change the URL itself. The <code>href</code> isn't text, it's an attribute.</p>
<p>Double-click is unclaimed real estate, so hang URL editing off it:</p>
<pre><code class="language-js">page.addEventListener('dblclick', e =&gt; {
  const a = e.target.closest('a');
  if (!a) return;
  e.preventDefault();
  const url = prompt('Link URL (leave empty to remove the link):', a.getAttribute('href'));
  if (url === null) return;
  if (!url.trim()) a.replaceWith(document.createTextNode(a.textContent));
  else a.setAttribute('href', url.trim());
});
</code></pre>
<p>Yes, <code>prompt()</code>. It's unfashionable, but consider what a custom modal would cost: markup, styles, focus management, and an escape handler, all for a dialog that asks one question. <code>prompt()</code> is native, keyboard-accessible, and can't break.</p>
<p>The empty-string branch is a nice touch: it unwraps the link entirely, replacing it with its own text, so "remove this link" doesn't require knowing any HTML.</p>
<p>Since none of this is discoverable, tell the user. A <code>title</code> attribute on every link ("Double-click to change this link") surfaces the affordance exactly where it's needed.</p>
<h2 id="heading-how-to-let-users-control-print-pagination">How to Let Users Control Print Pagination</h2>
<p>If the document's destination is a printed PDF, page breaks are content decisions, like "start my Skills section on page three," and the user should own them. CSS makes the break itself easy:</p>
<pre><code class="language-css">@media print {
  .page-break { break-before: page; page-break-before: always; }
  .job { break-inside: avoid; page-break-inside: avoid; }
}
</code></pre>
<p>The interesting part is the interface. The <code>⇟</code> button you saw in <code>makeBlock</code> just toggles the <code>page-break</code> class on a job or section. On screen, the class renders as a dashed accent line above the block, a visible seam showing where the printed page will end:</p>
<pre><code class="language-css">.page .page-break {
  border-top: 1.5px dashed var(--accent) !important;
  padding-top: 14px !important;
}

@media print {
  .page .page-break { border-top: none !important; padding-top: 0 !important; }
}
</code></pre>
<p>The dashed line exists only on screen. In print it vanishes and the actual break takes its place. The user toggles, glances at the seam, and prints. Nobody edits CSS to re-paginate a document, and just as importantly, nobody asks me to.</p>
<p>The same <code>@media print</code> block hides every piece of editing chrome (<code>.toolbar, .ctl, .add-btn { display: none !important; }</code>), so the printed output is indistinguishable from the original static page.</p>
<h2 id="heading-how-to-add-new-content-from-templates">How to Add New Content from Templates</h2>
<p>Editing and deleting only go so far. Eventually someone needs a new bullet, a new role, or a new certification. Each repeatable container gets a dashed "+ Add" button that builds a blank block from a template:</p>
<pre><code class="language-js">skillsSection.appendChild(newAddBtn('+ Add skill row', 'Adds a blank row. Type over the placeholder.', btn =&gt; {
  const row = document.createElement('div');
  row.className = 'skills-row';
  row.innerHTML =
    '&lt;span class="skill-label"&gt;Label&lt;/span&gt;' +
    '&lt;span class="skill-items"&gt;Skill one, skill two, skill three&lt;/span&gt;';
  skillsSection.insertBefore(row, btn);
  makeBlock(row, '.skills-row');
  selectText(row.querySelector('.skill-label'));
}));
</code></pre>
<p>Two details here do most of the work.</p>
<p>New blocks go through the same <code>makeBlock</code> as everything parsed at load. There is exactly one code path for "this is a block now", so added content is immediately movable and deletable. For roles it gets its own nested "+ Add bullet" button.</p>
<p>If you find yourself writing a second registration path for dynamic content, stop. You're about to fork behavior that must stay identical.</p>
<p>And <code>selectText</code> pre-selects the placeholder:</p>
<pre><code class="language-js">function selectText(node) {
  const range = document.createRange();
  range.selectNodeContents(node);
  const sel = getSelection();
  sel.removeAllRanges();
  sel.addRange(range);
}
</code></pre>
<p>Click "+ Add skill row" and the word <code>Label</code> is already highlighted, so typing replaces it. No clicking into the field, no manually deleting placeholder text, and no placeholders accidentally left in the printed document.</p>
<p>One caveat: select the <em>text node</em>, not the block. The block contains your <code>contenteditable="false"</code> control cluster, and a selection spanning it will delete your buttons along with the placeholder on the first keystroke.</p>
<h2 id="heading-why-nothing-persists">Why Nothing Persists</h2>
<p>Every edit lives in the DOM and dies on refresh. That sounds like the missing feature, but it's the design.</p>
<p>The workflow this page serves is: open, adjust, print to PDF, close. The PDF is the artifact. The page is a template you stamp from. Ephemerality gives you a free, bulletproof undo-everything (refresh), zero risk of a half-finished edit becoming the new baseline, and a canonical version that always matches source control.</p>
<p>The toolbar says it plainly: <em>"Nothing is saved. Refresh resets everything."</em> Stated upfront, it reads as a guarantee rather than a gotcha.</p>
<p>Persistence would also be the complexity cliff. The moment edits survive refresh you inherit serialization, versioning, merge conflicts with the source file, and "which copy is real?" Those are the exact problems this design exists to avoid.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a static HTML page that edits itself: <code>contenteditable</code> for text, one <code>makeBlock</code> function for structure, <code>:has()</code> for focused hover controls, a class toggle for print pagination, and templates with pre-selected placeholders for new content. Around a hundred lines of JavaScript, with no dependencies and no build.</p>
<p>Just as important is knowing when this approach stops being right. If edits must persist, if multiple people edit concurrently, or if the content needs validation and workflow, you've outgrown the DOM-as-state model. In those cases, reach for a real application and a database.</p>
<p>But for the wide middle ground of documents that one person adjusts and prints, such as résumés, invoices, certificates, and programmes, the browser already ships the editor. You just have to turn it on.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Trace and Monitor AI Agents with LangSmith ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, I'll show you how to trace and monitor a local AI agent with LangSmith. We'll build a small local AI agent and then enable LangSmith tracing for it so that we can inspect model calls ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-trace-and-monitor-ai-agents-with-langsmith/</link>
                <guid isPermaLink="false">6a611eaea47daf82ec9372d6</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ LLM&#39;s  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tracing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ langsmith ]]>
                    </category>
                
                    <category>
                        <![CDATA[ langchain ]]>
                    </category>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #qwen ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ollama ]]>
                    </category>
                
                    <category>
                        <![CDATA[ langfuse ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Darsh Shah ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 19:49:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6ff293d4-dea5-462b-b79b-c319d77458f0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, I'll show you how to trace and monitor a local AI agent with LangSmith. We'll build a small local AI agent and then enable LangSmith tracing for it so that we can inspect model calls, tool usage, and request latency in a web UI.</p>
<p>We'll be using LangChain v1, Ollama, Qwen, and Python. Everything runs on your own machine except the observability layer, so the agent itself has no model API costs.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-background">Background</a></p>
</li>
<li><p><a href="#heading-what-is-observability-and-monitoring">What is Observability and Monitoring?</a></p>
</li>
<li><p><a href="#heading-what-is-langsmith">What is LangSmith?</a></p>
</li>
<li><p><a href="#heading-motivation-and-architecture">Motivation and Architecture</a></p>
</li>
<li><p><a href="#heading-step-1-install-ollama-and-pull-the-model">Step 1: Install Ollama and Pull the Model</a></p>
</li>
<li><p><a href="#heading-step-2-install-python-dependencies">Step 2: Install Python Dependencies</a></p>
</li>
<li><p><a href="#heading-step-3-enable-langsmith-tracing">Step 3: Enable LangSmith tracing</a></p>
</li>
<li><p><a href="#heading-step-4-build-the-agent">Step 4: Build the agent</a></p>
</li>
<li><p><a href="#heading-sample-output">Sample output</a></p>
</li>
<li><p><a href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-background">Background</h2>
<p>Building a local AI agent is the easy part. The harder part starts later, when the agent behaves differently after a prompt change, starts using the wrong tool, or becomes slower without an obvious reason.</p>
<p>With regular software, we usually rely on logs and metrics to understand what changed. Agents need that too, but they also need visibility into the actual chain of decisions inside a request. A single user message might trigger a model call, one or more tool calls, and several intermediate steps before the final answer is returned.</p>
<p>If we only look at the final output, we miss most of what matters. We can tell that something went wrong, but not where it went wrong.</p>
<p>That’s why observability matters for AI agents. In this tutorial, we’ll set up LangSmith tracing for a local LangChain agent so we can inspect each request, see which tools were called, and understand how the agent behaved step by step</p>
<p>To follow along, you’ll need Ollama installed on your machine. The tutorial works on macOS, Windows, and Linux. I’m using a MacBook Pro with 32 GB of RAM, but you can run the same setup on a lower-memory machine by choosing a smaller Qwen model.</p>
<h2 id="heading-what-is-observability-and-monitoring">What is Observability and Monitoring?</h2>
<p>Monitoring tells us that something is wrong. It gives us signals like higher latency, more failures, more tool errors, or rising usage over time.</p>
<p>Observability helps us understand why it's wrong. It lets us inspect what happened inside a request. For an AI agent, that means looking at the prompt, the model calls, the tool calls, the outputs, and the timing for each step.</p>
<p>In practice, observability usually includes three things:</p>
<ul>
<li><p>Traces: the full step-by-step path of a request</p>
</li>
<li><p>Logs: records of events, outputs, and errors</p>
</li>
<li><p>Metrics: numbers tracked over time, like latency, failures, and usage</p>
</li>
</ul>
<p>For AI agents, this matters because the final answer alone usually isn’t enough. If the output is wrong or slow, we need a way to see whether the problem came from the model, the prompt, the tool choice, or something in the middle of the agent loop. The goal is to understand what happened and where it went wrong.</p>
<h2 id="heading-what-is-langsmith">What is LangSmith?</h2>
<p><a href="https://docs.langchain.com/langsmith/observability">LangSmith</a> is LangChain’s observability platform for tracing, debugging, evaluating, and monitoring LLM apps and agents.</p>
<p>The core concepts of LangSmith are:</p>
<ul>
<li><p>Project: a container for related traces</p>
</li>
<li><p>Trace: the full execution of one request</p>
</li>
<li><p>Run: an individual step inside a trace, such as an LLM call or tool call</p>
</li>
<li><p>Thread: a conversation or session grouping, useful for multi-turn agents</p>
</li>
</ul>
<p>LangChain agents built with <code>create_agent</code> automatically support LangSmith tracing, which means you can capture model calls, tool invocations, and execution steps with no code changes. The traces get automatically uploaded to LangSmith server on every agent invocation.</p>
<p>LangSmith features include request traces, step-by-step run inspection, latency and usage monitoring, dashboards, project-based organization, alerts for regressions, and more.</p>
<h2 id="heading-motivation-and-architecture">Motivation and Architecture</h2>
<p>Monitoring is the natural next step after building an agent. Once the agent works, the next question is whether it works reliably and whether we can debug it when it doesn’t. This becomes especially important in production, where debugging real user issues is much harder without traces, metrics, and request-level visibility.</p>
<p>To keep things simple, we’ll monitor a small local agent with two tools: one for the current time and another for counting words. The agent runs locally through Ollama, while LangSmith captures the trace data so we can inspect it in the browser and debug/monitor it.</p>
<h2 id="heading-step-1-install-ollama-and-pull-the-model">Step 1: Install Ollama and Pull the Model</h2>
<p>To get started, install the Ollama application for your platform. We'll use <code>qwen3.5:4b</code>.</p>
<pre><code class="language-plaintext">ollama pull qwen3.5:4b
</code></pre>
<p>If your machine has lower RAM, you can use qwen3.5:0.8b instead.</p>
<h2 id="heading-step-2-install-python-dependencies">Step 2: Install Python Dependencies</h2>
<p>Create a virtual environment and install the required packages:</p>
<pre><code class="language-plaintext">python3 -m venv venv 
source venv/bin/activate 
pip install langchain langchain-core langchain-ollama langsmith
</code></pre>
<p>This tutorial requires <code>langchain&gt;=1.0.0</code>.</p>
<h2 id="heading-step-3-enable-langsmith-tracing">Step 3: Enable LangSmith Tracing</h2>
<p>Create a free LangSmith account on <a href="https://smith.langchain.com">https://smith.langchain.com</a>. Once signed in, create a new project called MyAgentApp.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/b8b47668-8002-467b-a55f-310bce0e7772.png" alt="LangSmith page to create a new project. We will create MyAgentApp project" width="3410" height="1620" loading="lazy">

<p>Then generate an API key for the project, and set the environment variables in your terminal. The LangSmith webpage will show the values to set.</p>
<pre><code class="language-bash">export LANGSMITH_TRACING=true
export LANGSMITH_ENDPOINT=https://api.smith.langchain.com
export LANGSMITH_API_KEY=your_langsmith_api_key
export LANGSMITH_PROJECT="MyAgentApp"
</code></pre>
<p>At this point, your app is ready to send traces to LangSmith.</p>
<h2 id="heading-step-4-build-the-agent">Step 4: Build the Agent</h2>
<p>Below is a minimal AI agent using Ollama, LangChain, and two simple tools. This is the simpler version of the tool calling agent that we created in <a href="https://www.freecodecamp.org/news/how-to-build-your-own-local-ai-agent-with-tool-calling-and-memory/#heading-step-3-agent-python-code">How to Build Your Own Local AI Agent with Tool Calling and Memory</a>.</p>
<p>No additional tracing/LangSmith setup is required.</p>
<p>Save this file as <code>trace_agent.py</code>:</p>
<pre><code class="language-python">from datetime import datetime

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama

CHAT_MODEL = "qwen3.5:4b"   # Ollama chat model. Must support tool calling.

SYSTEM_PROMPT = (
    "You are a helpful assistant with access to tools for getting the current time and counting words in text. "
    "Use tools when the user's request needs one. "
    "If the question doesn't need a tool, answer directly. "
    "If a tool returns an error, explain the error plainly."
)

# ----- Tools -----
@tool
def current_time() -&gt; str:
    """Return the current local date and time.
    Use this when the user asks what time or date it is.
    """
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

@tool
def word_count(text: str) -&gt; int:
    """Count the number of words in a piece of text.
    Use this when the user asks how long a piece of writing is,
    or asks you to count the words in something they've shared.
    Returns the word count as an integer.
    """
    return len(text.split())


TOOLS = [current_time, word_count]


# ----- Agent -----

def build_agent():
    model = ChatOllama(model=CHAT_MODEL, reasoning=False, temperature=0)

    return create_agent(
        model=model,
        tools=TOOLS,
        system_prompt=SYSTEM_PROMPT
    )


def main():
    agent = build_agent()

    print("Ready! Ask the agent something.\n")

    # Track how many messages existed before this turn, so we can slice out
    # only the new ones (tool calls + final answer) from the returned state.
    prev_message_count = 0

    while True:
        question = input("You: ").strip()
        if not question or question.lower() == "exit":
            break

        result = agent.invoke(
            {"messages": [{"role": "user", "content": question}]}
        )

        # Only look at messages added during this turn, not the full history.
        new_messages = result["messages"][prev_message_count:]

        # Print any tool calls made in this turn.
        for msg in new_messages:
            tool_calls = getattr(msg, "tool_calls", None)
            if tool_calls:
                for call in tool_calls:
                    print(f"[tool call] {call['name']}({call['args']})")

        print(f"\nAnswer: {result['messages'][-1].content}\n")

        # Update the count for the next turn.
        prev_message_count = len(result["messages"])


if __name__ == "__main__":
    main()
</code></pre>
<p>Because this agent is created with LangChain’s agent APIs, LangSmith tracing should capture the end-to-end execution: input, model interactions, tool calls, and final output without any additional configuration.</p>
<p>Run the agent:</p>
<pre><code class="language-plaintext">python trace_agent.py
</code></pre>
<h2 id="heading-sample-output">Sample Output</h2>
<p>The output looks like below. I asked the agent four questions. It invoked tools for finding the time and word length.</p>
<pre><code class="language-text">$python trace_agent.py 
Ready! Ask the agent something.

You: Hello, how are you?

Answer: I'm doing well! How about you? Is there anything specific I can help you with today?

You: What is the current time
[tool call] current_time({})

Answer: The current local date and time is July 17, 2026 at 13:56. Is there anything else you'd like to know?

You: What is the word count for "LangSmith is awesome"
[tool call] word_count({'text': 'LangSmith is awesome'})

Answer: The phrase "LangSmith is awesome" has a word count of 3. Let me know if you need anything else!

You: What is capital of France

Answer: The capital of France is Paris.
</code></pre>
<p>Now, we'll see how LangSmith traced the request. Go to the LangSmith Web UI and sign in. Click on your project and you can see:</p>
<ul>
<li><p>traces in your project</p>
</li>
<li><p>the request and responses</p>
</li>
<li><p>tool calling information</p>
</li>
<li><p>token consumption</p>
</li>
<li><p>latency information and other key metrics</p>
</li>
</ul>
<p>For the above output, I can see four traces (each agent invocation creates its own trace):</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/a2f80d11-8bb5-4f43-a937-20a01bef3607.png" alt="Image showing all four traces in MyAgentApp project in LangSmith UI" width="3300" height="1144" loading="lazy">

<p>Inspecting trace 2, I can see the request, response, and tool calling information. I can also see the tokens consumed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/b871eeda-9efd-453f-a966-185393384868.png" alt="Image showing one trace request and response  in MyAgentApp project in LangSmith UI" width="2854" height="1700" loading="lazy">

<p>I can see the overall count, latency, error rate, and other metrics for my app. This can help in checking the overall usage and health of your AI agent.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/c1863bf6-f383-4205-915d-bad6a315bade.png" alt="Image showing monitoring dashboard with count, latency and error rate metrics in LangSmith UI" width="2812" height="1816" loading="lazy">

<p>Lastly, I can setup alerts to monitor and notify if something goes wrong. For example, we can configure an alert called HighUsage and it will alert if the run count is more than once in the last 5 minutes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/c4d11b88-5ceb-4e4e-8614-e18bd2eb1c94.png" alt="Image showing Alert setup window in LangSmith UI. " width="3118" height="1540" loading="lazy">

<p>The above setup gives you a very quick way to setup observability and monitoring for your AI Agent.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>Once tracing works, the next improvement is to add metadata and tags so traces become easier to filter and analyze. LangSmith supports custom metadata and tags to label requests by environment, app version, user tier, or workflow.</p>
<p>For example, you might add the below option in the config:</p>
<ul>
<li><p><code>environment=dev</code></p>
</li>
<li><p><code>agent_name=local-ollama-agent</code></p>
</li>
<li><p><code>model=qwen3</code></p>
</li>
</ul>
<pre><code class="language-python">result = agent.invoke(
            {"messages": [{"role": "user", "content": question}]},

config={
        "tags": ["dev", "local-ollama-agent"],
        "metadata": {
            "environment": "dev",
            "agent_name": "local-ollama-agent",
            "model": "qwen3"
        }
    }
)
</code></pre>
<p>This becomes useful when comparing across agents, models and enviroments.</p>
<p>One caveat is that LangSmith is proprietary. Using it means your trace data is sent to LangSmith’s hosted service, and there's usually a cost attached as your usage grows. For this tutorial, it's free as the trace volume is low. For most projects, it will be fine to use LangSmith.</p>
<p>An open-source alternative to LangSmith is <a href="https://langfuse.com">Langfuse</a>. It provides LLM observability with traces, sessions, metadata, dashboards, and metrics, and it can be self-hosted. It provides similar features like capturing traces of LLM calls, tool executions, timing, inputs, outputs, and metadata, along with customizable dashboards and metadata-based filtering.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, we took a local AI agent and added observability with LangSmith using LangChain v1, Ollama, Qwen, and Python. The result is a simple monitoring and observability setup that shows what the agent did, which tools it called, and how long each step took.</p>
<p>From here, you can extend the setup by adding metadata, creating separate projects for dev and prod, or trying an open-source alternative like Langfuse. The core loop stays the same: run the agent, capture the trace, inspect the result, and use that signal to improve the system.</p>
<p>If you enjoyed this tutorial, you can find more of my writing on my <a href="http://darshshah.org/blog">blog</a> (recent posts include a system design paper series), my work on my personal <a href="https://darshshah.org/">website</a>, and updates on <a href="https://www.linkedin.com/in/darshs">LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Train a Tumor Segmentation Model on Ultrasound Data with MONAI ]]>
                </title>
                <description>
                    <![CDATA[ Most segmentation tutorials begin by choosing a model, feeding images into it, and tuning hyperparameters until the metric improves. But this skips the step that often matters most: understanding the  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-train-a-tumor-segmentation-model-on-ultrasound-data-with-monai/</link>
                <guid isPermaLink="false">6a60f5843dee1fe3a0faaca9</guid>
                
                    <category>
                        <![CDATA[ Healthcare AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Deep Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ medical image segmentation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 16:53:24 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3e7cfe47-858c-4ce9-b8f9-1c5fc22f29b7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most segmentation tutorials begin by choosing a model, feeding images into it, and tuning hyperparameters until the metric improves. But this skips the step that often matters most: understanding the data.</p>
<p>In this tutorial we’ll profile the dataset first, then let those observations drive every design decision in a MONAI segmentation pipeline.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ul>
<li><p><a href="#heading-who-is-this-for">Who is This For?</a></p>
</li>
<li><p><a href="#heading-about-the-dataset">About the Dataset</a></p>
</li>
<li><p><a href="#heading-what-is-monai-and-why-use-it">What is MONAI, and Why Use it?</a></p>
</li>
<li><p><a href="#heading-what-is-dice">What is Dice?</a></p>
</li>
<li><p><a href="#heading-part-1-data-profile-before-modeling">Part 1 — Data Profile Before Modeling</a></p>
<ul>
<li><p><a href="#heading-class-balance-drives-the-toss">Class Balance Drives the Toss</a></p>
</li>
<li><p><a href="#heading-patient-counts-drive-the-split">Patient Counts Drive the Split</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-building-the-pipeline">Part 2 — Building the Pipeline</a></p>
<ul>
<li><p><a href="#heading-a-single-config-object">A Single Config Object</a></p>
</li>
<li><p><a href="#heading-the-patient-grouped-split">The Patient-grouped Split</a></p>
</li>
<li><p><a href="#heading-transforms-chosen-by-the-snapshot">Transforms, Chosen by the Snapshot</a></p>
</li>
<li><p><a href="#heading-model-loss-and-metric">Model, Loss, and Metric</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-reading-the-results">Reading the Results</a></p>
</li>
<li><p><a href="#heading-prediction-visualization">Prediction Visualization</a></p>
</li>
<li><p><a href="#heading-the-failure-modes-matter-more-than-the-average">The Failure Modes Matter More Than the Average</a></p>
</li>
<li><p><a href="#heading-where-to-go-next">Where to Go Next</a></p>
</li>
<li><p><a href="#heading-takeaway">Takeaway</a></p>
</li>
<li><p><a href="#heading-reference">Reference</a></p>
</li>
</ul>
<h2 id="heading-who-is-this-for">Who is This For?</h2>
<p>This walkthrough assumes you have some comfort with Python and the basics of training a neural network. It explains the MONAI-specific pieces (dictionary transforms, <code>DiceCELoss</code>, <code>DiceMetric</code>) and the medical-imaging terms (BI-RADS, hypoechoic, patient-grouped folds) as they come up. No prior ultrasound experience is needed.</p>
<h2 id="heading-about-the-dataset">About the Dataset</h2>
<p>The dataset is <a href="https://www.kaggle.com/datasets/orvile/bus-bra-a-breast-ultrasound-dataset">BUS-BRA</a>, a public collection of breast ultrasound images with biopsy-proven labels and tumor segmentation masks.</p>
<p>Each image carries a benign/malignant label, a BI-RADS (Breast Imaging Reporting and Data System)&nbsp;category (a radiologist's suspicion score from 2 to 5), a histology string, and a binary tumor mask. The CSV that ships with it also includes predefined cross-validation folds.</p>
<p>The task is binary: separate tumor from background. BUS-BRA contains 1,875 B-mode breast ultrasound images from 1,064 patients, acquired on four scanners at a cancer institute in Brazil.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/24ecadc1-13ff-4dfb-a6fc-61ab03785a7e.png" alt="Example from the BUS-BRA dataset showing a breast ultrasound image, its binary tumor segmentation mask, and the mask overlaid on the original image." style="display:block;margin:0 auto" width="640" height="409" loading="lazy">

<h2 id="heading-what-is-monai-and-why-use-it">What is MONAI, and Why Use it?</h2>
<p>MONAI (Medical Open Network for AI) is an open-source PyTorch framework built specifically for medical imaging. It's a domain-specific layer that sits on top of PyTorch: you still write standard PyTorch training loops, but MONAI provides the medical imaging-specific components so you don't have to build them yourself.</p>
<p>It gives you:</p>
<ul>
<li><p><strong>Transforms</strong> for medical data, loading formats like DICOM and NIfTI, normalizing intensities, resizing, and augmenting, all in a dictionary-based pipeline that keeps an image and its mask in sync.</p>
</li>
<li><p><strong>Network architectures</strong> common in medical segmentation (U-Net, UNETR, SegResNet, and others) ready to instantiate.</p>
</li>
<li><p><strong>Loss functions and metrics</strong> designed for segmentation, including Dice-based losses and the Dice metric.</p>
</li>
</ul>
<p>The result is less boilerplate and fewer chances for an image and its mask to drift out of alignment.</p>
<h2 id="heading-what-is-dice">What is Dice?</h2>
<p>Dice (the Dice similarity coefficient) measures how much two regions overlap. In segmentation, it compares the model's predicted mask against the ground-truth mask and returns a score from 0 to 1: 0 means no overlap at all, 1 means a perfect match.</p>
<p>The formula is:</p>
<p><code>Dice = 2 × (overlap) / (predicted area + true area)</code></p>
<p>The "2 ×" in the numerator is what keeps the score in the 0-to-1 range even though the denominator counts the overlapping pixels on both sides.</p>
<p>Two roles it plays in this tutorial:</p>
<ul>
<li><p>As a <strong>metric</strong>, Dice is how the run is scored. A validation Dice of 0.876 means the predicted tumor masks overlap the true masks by about 88% on average.</p>
</li>
<li><p>As a <strong>loss</strong> (<code>DiceCELoss</code>), a Dice-based term is what the model trains against. This is the part that matters for the class-imbalance problem: because Dice measures overlap rather than per-pixel correctness, a model can't score well by labeling everything as background. A small tumor counts as much as a large one, so the model is pushed to actually find the tumor region.</p>
</li>
</ul>
<h2 id="heading-part-1-data-profile-before-modeling">Part 1 — Data Profile Before Modeling</h2>
<p>This first pass is data profiling. It reads every image and mask once and answers a short list of questions whose answers determine how the pipeline must be built. Running these checks takes a few seconds and saves a lot of guesswork later.</p>
<p>The snapshot below summarizes the properties that directly influenced the pipeline design. We’ll let these observations determine each step of the workflow.</p>
<table>
<thead>
<tr>
<th>What the snapshot measured</th>
<th>The number</th>
<th>What it forces</th>
</tr>
</thead>
<tbody><tr>
<td>Distinct image resolutions</td>
<td>Hundreds of different (width, height) pairs</td>
<td>Images must be resized to a fixed size before batching</td>
</tr>
<tr>
<td>Class balance</td>
<td>Background : foreground ≈ 10.6 : 1</td>
<td>A plain pixel-wise loss may converge toward predicting mostly background because doing so already yields high pixel accuracy on this imbalanced dataset.</td>
</tr>
<tr>
<td>Per-image brightness</td>
<td>Wide spread across the dataset</td>
<td>Intensity normalization belongs in the transform pipeline</td>
</tr>
<tr>
<td>Patients vs. images</td>
<td>1,064 patients, 1,875 images (paired left/right views)</td>
<td>Splits must be grouped by patient, or the same person leaks across train and validation</td>
</tr>
<tr>
<td>Mask components</td>
<td>Every mask is a single connected region</td>
<td>A prediction with several disconnected blobs is provably wrong</td>
</tr>
<tr>
<td>Pixel format</td>
<td>Images are 8-bit grayscale, masks are 1-bit binary</td>
<td>Load as single-channel, binarize the mask after loading</td>
</tr>
</tbody></table>
<p>Two of these deserve a closer look because they shape the two most important decisions.</p>
<h3 id="heading-class-balance-drives-the-toss">Class Balance Drives the Toss</h3>
<p>Tumors are small. Across the dataset, background pixels outnumber tumor pixels by more than ten to one.</p>
<p>A model trained with ordinary binary cross-entropy can score around 91% pixel accuracy by labeling everything as background. This high number reflects the imbalance rather than any ability to find the tumor.</p>
<p>The fix is a loss that rewards overlap with the actual tumor region, which points directly at Dice.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/69c14498-7119-4f15-a29b-9bf34fdcd51f.png" alt="Bar chart comparing foreground and background pixels in the BUS-BRA dataset. Background pixels outnumber tumor pixels by approximately 10.6 to 1, illustrating the strong class imbalance." style="display:block;margin:0 auto" width="1731" height="649" loading="lazy">

<h3 id="heading-patient-counts-drive-the-split">Patient Counts Drive the Split</h3>
<p>There are fewer patients than images because many patients contribute both a left-side and a right-side scan. If a random split puts one patient's left scan in training and their right scan in validation, the validation score is inflated by leakage.</p>
<p>The dataset authors already solved this: the CSV ships a <code>K5P</code> column: a 5-fold split where <strong>P</strong> stands for patient-grouped, meaning every image from a given patient lands in the same fold. Reusing it is safer than rebuilding the same grouping by hand.</p>
<p>With those answers in hand, the pipeline has a specification to build now.</p>
<h2 id="heading-part-2-building-the-pipeline">Part 2 — Building the Pipeline</h2>
<p>Everything below uses MONAI for the segmentation-specific work:<br>transforms, dataset wrapping, the network, the loss, and the metric.</p>
<h3 id="heading-a-single-config-object">A Single Config Object</h3>
<p>The pipeline reads all its knobs from one dataclass. Nothing downstream hard-codes a constant, so re-running an experiment with a different fold or image size is a single edit.</p>
<pre><code class="language-python">from dataclasses import dataclass
from typing import Tuple, Optional
from pathlib import Path

@dataclass
class TrainConfig:
    data_root: Optional[Path] = None
     fold_column: str = "K5P"          # patient-grouped 5-fold (dev set)
    val_fold: int = 1                 # which K5P fold is validation
    test_column: str = "HOP"          # patient-grouped hold-out partition
    test_group: int = 1               # HOP value reserved as the test set

    image_size: Tuple[int, int] = (256, 256)
    batch_size: int = 16
    lr: float = 1e-3
    epochs: int = 30
    use_amp: bool = True              # mixed precision
    ckpt_path: str = "best_model.pt"

cfg = TrainConfig()
</code></pre>
<p>The code above defines a <code>TrainConfig</code> dataclass holding every setting the pipeline needs: the fold column and which fold to validate on, the target image size, batch size, learning rate, epoch count, a mixed-precision switch, and where to save the best model. Creating <code>cfg</code> once gives every later step a single place to read its settings from.</p>
<h3 id="heading-the-patient-grouped-split">The Patient-grouped Split</h3>
<p>The split uses two predefined columns. <code>HOP</code> (Hold-Out Partition) reserves a patient-disjoint slice as the test set, untouched until the very end. Within the remaining development set, one <code>K5P</code> fold becomes validation and the other four are training. Short assertions confirm no patient appears in more than one split.</p>
<pre><code class="language-python">dev_df   = manifest[manifest[cfg.test_column] != cfg.test_group]
test_df  = manifest[manifest[cfg.test_column] == cfg.test_group]

train_df = dev_df[dev_df[cfg.fold_column] != cfg.val_fold]
val_df   = dev_df[dev_df[cfg.fold_column] == cfg.val_fold]

# no patient may appear in more than one split
for a, b in [(train_df, val_df), (train_df, test_df), (val_df, test_df)]:
    assert not (set(a["Case"]) &amp; set(b["Case"])), "patient leakage"
</code></pre>
<p>The above code first splits off the <code>HOP</code> test set, then divides the remaining development rows into validation (the chosen <code>K5P</code> fold) and training (the rest). It then checks that every pair of splits shares no patient <code>Case</code>. If any does, the assertion fails immediately.</p>
<h3 id="heading-transforms-chosen-by-the-snapshot">Transforms, Chosen by the Snapshot</h3>
<p>MONAI's dictionary transforms operate on records keyed by name (<code>"image"</code> and <code>"label"</code>) and apply matched operations to both. Each step here answers a <strong>Part 1 data profile</strong> finding.</p>
<pre><code class="language-python">from monai.transforms import (
    Compose, LoadImaged, EnsureChannelFirstd, ScaleIntensityd,
    AsDiscreted, Resized, RandFlipd, EnsureTyped,
)
import torch

base = [
    LoadImaged(keys=["image", "label"], reader="PILReader", image_only=True),
    EnsureChannelFirstd(keys=["image", "label"]),
    ScaleIntensityd(keys="image"),                       # brightness spread
    AsDiscreted(keys="label", threshold=0.5),            # clean {0, 1} mask
    Resized(keys=["image", "label"],                     # hundreds of sizes
            spatial_size=cfg.image_size,
            mode=("bilinear", "nearest")),
]

train_transforms = Compose(base + [
    RandFlipd(keys=["image", "label"], prob=0.5, spatial_axis=1),  # horizontal
    EnsureTyped(keys=["image", "label"], dtype=torch.float32),
])
val_transforms = Compose(base + [
    EnsureTyped(keys=["image", "label"], dtype=torch.float32),
])
</code></pre>
<p>The above code builds a shared list of base steps, loads the PNG, moves the channel to the front, scales the image to [0, 1], binarizes the mask, and resizes both to 256×256. It then wraps that list in two pipelines. The training pipeline adds a random horizontal flip, and the validation pipeline does not, so evaluation always sees the image as-is.</p>
<p>Horizontal flips are a simple augmentation that preserve anatomical plausibility in this dataset. More aggressive augmentations, such as large rotations or elastic deformations, should be validated carefully because they may distort clinically meaningful structures.</p>
<p>Images use bilinear interpolation to preserve intensity gradients, while masks use nearest-neighbor interpolation so class labels remain strictly 0 or 1. Bilinear interpolation on masks would create artificial label values along object boundaries.</p>
<h3 id="heading-model-loss-and-metric">Model, Loss, and Metric</h3>
<p>The network is a MONAI <code>UNet</code> with one input channel (grayscale) and one output channel (the tumor logit). The loss is the one the class-balance finding pointed at.</p>
<p>U-Net consists of an encoder that captures context at progressively coarser resolutions and a decoder that reconstructs fine spatial detail. Skip connections transfer high-resolution features directly from encoder to decoder, making U-Net especially effective for medical segmentation where boundaries matter.</p>
<pre><code class="language-python">from monai.networks.nets import UNet
from monai.losses import DiceCELoss
from monai.metrics import DiceMetric
from monai.transforms import Activations, AsDiscrete

model = UNet(
    spatial_dims=2, in_channels=1, out_channels=1,
    channels=(16, 32, 64, 128, 256), strides=(2, 2, 2, 2),
    num_res_units=2,
).to(device)

loss_fn = DiceCELoss(sigmoid=True)       # Dice handles the imbalance; CE smooths the gradient
metric  = DiceMetric(include_background=True, reduction="mean")
post_pred = Compose([Activations(sigmoid=True), AsDiscrete(threshold=0.5)])
   
</code></pre>
<p>The above code creates the U-Net (five resolution levels, one input and one output channel) and moves it to the GPU. It then defines the three pieces that surround it: the loss, the validation metric, and a <code>post_pred</code> step that turns raw model outputs into a clean 0/1 mask by applying a sigmoid and thresholding at 0.5.</p>
<p><code>DiceCELoss</code> combines two terms. The Dice part is scale-invariant in the foreground area, so a small tumor counts as much as a large one and the model can't win by ignoring tumors. The cross-entropy part adds a smoother gradient where Dice is flat. The <code>sigmoid=True</code> flag tells the loss to apply the activation itself, so the model outputs raw logits and the <code>post_pred</code> step handles the sigmoid-and-threshold at evaluation time. This U-Net comes out to about 1.6 million parameters.</p>
<p>The training loop itself is mostly standard PyTorch. MONAI stays out of the optimization logic, the only segmentation-specific pieces are the loss, transforms, and evaluation metric.</p>
<pre><code class="language-python">for epoch in range(1, cfg.epochs + 1):
    model.train()
    for batch in train_loader:
        img, lab = batch["image"].to(device), batch["label"].to(device)
        optimizer.zero_grad(set_to_none=True)
        with torch.amp.autocast("cuda", enabled=cfg.use_amp):
            loss = loss_fn(model(img), lab)
        scaler.scale(loss).backward()
        scaler.step(optimizer); scaler.update()

    model.eval(); metric.reset()
    with torch.no_grad():
        for batch in val_loader:
            img, lab = batch["image"].to(device), batch["label"].to(device)
            pred = post_pred(model(img))
            metric(y_pred=pred, y=lab)
    val_dice = metric.aggregate().item()
    if val_dice &gt; best_dice:
        best_dice = val_dice
        torch.save(model.state_dict(), cfg.ckpt_path)
</code></pre>
<p>In the above code, each epoch runs two passes. The training pass moves every batch to the GPU, computes the loss under mixed precision, and updates the weights through the gradient scaler. The validation pass then runs with gradients turned off, converts predictions with <code>post_pred</code>, and accumulates Dice across the fold. Whenever the epoch's Dice beats the best seen so far, the model weights are saved to disk.</p>
<h2 id="heading-reading-the-results">Reading the Results</h2>
<p>Two curves summarize the run. Training loss falls steadily and flattens near 0.12. Validation Dice climbs from about 0.57 to a plateau, with a best of <strong>0.866</strong> reached at epoch 28.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/2a70807b-9d3d-4ff1-9610-7100aaaac984.png" alt="Training curves showing loss decreasing steadily over 30 epochs while validation Dice increases and plateaus around 0.866, indicating convergence with mild overfitting." style="display:block;margin:0 auto" width="1526" height="470" loading="lazy">

<p>A few things are worth reading off these curves:</p>
<ul>
<li><p>The loss decreasing monotonically means the model is learning. The gradient signal is real.</p>
</li>
<li><p>The loss flattening above zero rather than reaching it is expected. <code>DiceCELoss</code> has a floor, because the cross-entropy term never fully vanishes on ambiguous boundary pixels. A loss that reached zero would be a warning sign, not a triumph.</p>
</li>
<li><p>Validation Dice plateauing above ~0.85 while training loss keeps falling is the mild-overfitting signature. Extra epochs mostly lower train loss without moving val Dice. It's not severe here, so the 30-epoch budget is fine, but a patience-based early-stopping rule would be a reasonable add.</p>
</li>
</ul>
<p>A validation Dice of 0.866 sits in a reasonable range for a plain 2D U-Net on this dataset. But validation Dice measures a checkpoint chosen using that same set, so it runs a little optimistic.</p>
<p>The final, untouched check is the <code>HOP</code> test set, scored exactly once, after all training and model selection are done. It comes in at <strong>0.864</strong>, essentially matching the 0.866 validation figure. The model generalizes to patients it never saw during training or selection, and the validation number wasn't hiding leakage.</p>
<h2 id="heading-prediction-visualization"><strong>Prediction Visualization</strong></h2>
<p>Metrics summarize overall performance, but they don’t show <em>how</em> the model is segmenting individual tumors.</p>
<p>The figure below presents a representative validation example. From left to right are the input ultrasound image, the ground-truth mask, the model’s predicted mask, and the prediction overlaid on the original image.</p>
<p>The close agreement between the prediction and the ground-truth annotation illustrates how the model localizes both the position and the boundary of the lesion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/ada82859-b2ba-4fd4-bc45-086177990a3f.png" alt="Four-panel visualization showing a representative segmentation result: the original breast ultrasound image, the ground-truth tumor mask, the model’s predicted mask, and the predicted mask overlaid on the original image. The prediction closely matches the annotated tumor boundary." style="display:block;margin:0 auto" width="1597" height="1129" loading="lazy">

<h2 id="heading-the-failure-modes-matter-more-than-the-average">The Failure Modes Matter More Than the Average</h2>
<p>An average Dice of 0.866 can hide very different behaviors. It could mean every case is mediocre, or most cases are excellent and a few fail badly.</p>
<p>To distinguish between those possibilities, sort the validation set by per-image Dice and inspect the lowest-scoring predictions.</p>
<p>On this fold, only 4 of 299 validation cases scored below 0.5, about 1%. Looking at those four overlays surfaces a clear pattern. Three of the four worst predictions are <strong>fragmented</strong>: the model outputs several disconnected blobs where the ground truth is a single region. The fourth confuses a dark acoustic shadow, a common ultrasound artifact, for tumor tissue.</p>
<p>That fragmentation pattern connects straight back to a snapshot finding: the data-quality pass measured that <strong>every ground-truth mask in BUS-BRA is a single connected component</strong>. So a multi-blob prediction is wrong by a property of the dataset, which points at keeping only the largest connected component as a post-processing step:</p>
<pre><code class="language-python">from monai.transforms import KeepLargestConnectedComponent

post_pred = Compose([
    Activations(sigmoid=True),
    AsDiscrete(threshold=0.5),
    KeepLargestConnectedComponent(applied_labels=[1]),
])
</code></pre>
<p>This code rebuilds the <code>post_pred</code> pipeline with one extra step at the end. After the sigmoid and threshold produce a binary mask, <code>KeepLargestConnectedComponent</code> discards every predicted region except the largest one, so a prediction split into several blobs collapses to its single biggest piece. This matches the dataset's one-region-per-mask property.</p>
<p>I measured this on the validation set, and the honest result is more nuanced than "free accuracy." It recovers a few of the fragmented cases, but the net change in mean Dice is marginal and can even go slightly negative. When a real lesion is predicted as two touching pieces, discarding the smaller one throws away true-positive area. So it's a targeted lever for a specific failure mode, not a free boost: worth exploring, not adopting blindly.</p>
<p>The shadow-confusion case is harder still, telling a hypoechoic tumor from a dark shadow region sometimes needs context a small grayscale crop doesn't carry. This points toward higher resolution or a wider receptive field as directions for later experiments.</p>
<h2 id="heading-where-to-go-next">Where to Go Next</h2>
<p>Once you have a reliable baseline, the next experiments become much more meaningful. Rather than randomly trying larger models, start from the failure modes you observed:</p>
<ul>
<li><p>Replace the 2D U-Net with Attention U-Net or DynUNet.</p>
</li>
<li><p>Train at higher resolution to better capture small lesions.</p>
</li>
<li><p>Apply connected-component analysis selectively during inference.</p>
</li>
<li><p>Explore test-time augmentation.</p>
</li>
<li><p>Compare DiceCE with Focal Tversky loss for highly imbalanced lesions.</p>
</li>
</ul>
<h2 id="heading-takeaway">Takeaway</h2>
<p>The through-line is profile the data, then let what you find make the decisions.</p>
<p>The resize came from a resolution check. The loss came from a class-balance check. The split came from a patient count. The most useful post-processing idea came from a mask-component check run before training started. None of these were guesses, and none of them needed a sweep to discover.</p>
<p>A model is easy to build. A model whose every choice has a reason behind it is easier to trust, easier to debug, and easier to explain to the person who reads it next.</p>
<h2 id="heading-reference">Reference</h2>
<p>The complete, runnable code for this walkthrough is available as a MONAI notebook: <a href="https://github.com/lakshmi-mahabaleshwara/wg-ultrasound/tree/bus_bra_tumor_segmentation/data_and_tutorials/bus_bra_tutor_segmentation"><code>busbra_segmentation_monai.ipynb</code></a>. It runs top to bottom on Kaggle or Colab, and auto-downloads the dataset if it's not already attached.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Reusable Date-Time Picker in React with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ A date and time picker is one of those components that looks small in a design file and turns into a real time sink once you start building it. You need a calendar, a time selector, a state that keeps ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-reusable-date-time-picker-in-react-with-shadcn-ui/</link>
                <guid isPermaLink="false">6a60f42cb1ecbbb606535a7b</guid>
                
                    <category>
                        <![CDATA[ shadcn ui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 16:47:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7dcddc1c-2a9d-4af7-8f02-ffb76bea2c7b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A date and time picker is one of those components that looks small in a design file and turns into a real time sink once you start building it. You need a calendar, a time selector, a state that keeps both in sync, and usually a range mode and a translated version somewhere down the line, too.</p>
<p>This guide walks through ready-made picker patterns you can drop into a React project today: a combined date and time picker, a date range picker, and a time picker.</p>
<p>Every one of these is available as a <a href="https://shadcnspace.com/components/date-picker"><strong>Shadcn Date Picker</strong></a> component you can install with a single CLI command instead of building from scratch.</p>
<p>These components are built on both Radix and Base UI primitives, and the versions below use Base UI. They also support copy-prompt functionality, so you can paste them straight into v0, Lovable, or Bolt if that's part of your workflow.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-install-a-shadcn-date-time-picker">How to Install a Shadcn Date Time Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-date-and-time-picker">How to Build a Date and Time Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-date-range-picker">How to Build a Date Range Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-time-picker">How to Build a Time Picker</a></p>
</li>
<li><p><a href="#heading-live-preview-of-the-components">Live Preview of the components</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should know:</p>
<ul>
<li><p>The basics of React, including <code>useState</code> and props</p>
</li>
<li><p>How to install components with the shadcn/ui CLI</p>
</li>
<li><p>Basic Tailwind CSS class names</p>
</li>
</ul>
<p>You also need a React project with shadcn/ui already set up. If you haven't done that yet, run the shadcn/ui CLI setup command in your project before continuing.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<ul>
<li><p>A <code>DateTimePicker</code> component that combines a calendar and time slots into one value.</p>
</li>
<li><p>A <code>TimePicker</code> variant that reuses the same time-slot logic without a calendar.</p>
</li>
<li><p>A <code>DateRangePicker</code> that lets a user pick a start and end date.</p>
</li>
</ul>
<h2 id="heading-how-to-install-a-shadcn-date-time-picker"><strong>How to Install a Shadcn Date Time Picker</strong></h2>
<p>All the components below install through the same CLI pattern. Pick the package manager you use:</p>
<p><strong>pnpm</strong></p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>npm</strong></p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>yarn</strong></p>
<pre><code class="language-javascript">yarn dlx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>bun</strong></p>
<pre><code class="language-javascript">bunx --bun shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p>Every other component below installs the same way: just swap the package name at the end of the command. If you haven't set up the CLI in your project yet, this <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>getting-started guide</strong></a> covers that first and shows how to integrate these components when you're working through an MCP-connected editor.</p>
<h2 id="heading-how-to-build-a-date-and-time-picker"><strong>How to Build a Date and Time Picker</strong></h2>
<p>This is the combined picker: a calendar popover for the date, plus start and end time fields, wrapped around a booking confirmation flow.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-01.tsx
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import { useState } from "react";
import { format } from "date-fns";
import { CalendarIcon, Clock, ChevronDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";

import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

const DateAndTimePickerDemo = () =&gt; {
  const [open, setOpen] = useState(false);
  const [date, setDate] = useState&lt;Date | undefined&gt;(undefined);
  const [bookingStatus, setBookingStatus] = useState&lt;
    "idle" | "loading" | "success"
  &gt;("idle");

  const handleBooking = () =&gt; {
    setBookingStatus("loading");
    setTimeout(() =&gt; setBookingStatus("success"), 1500);
  };

  return (
    &lt;&gt;
      &lt;div className="grid gap-6"&gt;
        &lt;div className="grid gap-2"&gt;
          &lt;Label htmlFor="date" className="text-sm font-semibold"&gt;
            Select Date
          &lt;/Label&gt;
          &lt;Popover open={open} onOpenChange={setOpen}&gt;
            &lt;PopoverTrigger
              onPointerDown={() =&gt; setBookingStatus("idle")}
              render={
                &lt;Button
                  variant="outline"
                  id="date"
                  className={cn(
                    "w-full justify-start text-left font-normal h-10 transition-all hover:bg-muted/50 cursor-pointer",
                    !date &amp;&amp; "text-muted-foreground",
                  )}
                &gt;
                  &lt;CalendarIcon className="mr-2 h-4 w-4 opacity-70" /&gt;
                  {date ? format(date, "PPP") : &lt;span&gt;Select a date&lt;/span&gt;}
                  &lt;ChevronDown className="ml-auto h-4 w-4 opacity-50" /&gt;
                &lt;/Button&gt;
              }
            /&gt;
            &lt;PopoverContent
              className="w-auto p-0 border-muted-foreground/10 shadow-2xl"
              align="start"
            &gt;
              &lt;Calendar
                mode="single"
                selected={date}
                onSelect={(d) =&gt; {
                  setDate(d);
                  setOpen(false);
                }}
                className="rounded-md border-none"
              /&gt;
            &lt;/PopoverContent&gt;
          &lt;/Popover&gt;
        &lt;/div&gt;

        &lt;div className="grid grid-cols-2 gap-4"&gt;
          &lt;div className="grid gap-2"&gt;
            &lt;Label
              htmlFor="time-from"
              className="text-sm font-semibold text-muted-foreground flex items-center gap-1.5"
            &gt;
              &lt;Clock className="size-3.5" /&gt; Start Time
            &lt;/Label&gt;
            &lt;Input
              type="time"
              id="time-from"
              defaultValue="09:00"
              className="h-10 bg-background appearance-none transition-all focus:ring-2 focus:ring-primary/20"
            /&gt;
          &lt;/div&gt;
          &lt;div className="grid gap-2"&gt;
            &lt;Label
              htmlFor="time-to"
              className="text-sm font-semibold text-muted-foreground flex items-center gap-1.5"
            &gt;
              &lt;Clock className="size-3.5" /&gt; End Time
            &lt;/Label&gt;
            &lt;Input
              type="time"
              id="time-to"
              defaultValue="10:00"
              className="h-10 bg-background appearance-none transition-all focus:ring-2 focus:ring-primary/20"
            /&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;Button
          className="w-full h-11 font-semibold transition-all group overflow-hidden relative cursor-pointer"
          onClick={handleBooking}
          disabled={!date || bookingStatus !== "idle"}
        &gt;
          {bookingStatus === "idle" &amp;&amp; (
            &lt;span className="flex items-center gap-2"&gt;Confirm Meet&lt;/span&gt;
          )}
          {bookingStatus === "loading" &amp;&amp; (
            &lt;div className="flex items-center gap-2"&gt;
              &lt;div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /&gt;
              Processing...
            &lt;/div&gt;
          )}
          {bookingStatus === "success" &amp;&amp; (
            &lt;span className="flex items-center gap-2 animate-in zoom-in-50 duration-300"&gt;
              &lt;Check className="h-4 w-4" /&gt;
              Meet Scheduled!
            &lt;/span&gt;
          )}
        &lt;/Button&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
};

export default DateAndTimePickerDemo;
</code></pre>
<h3 id="heading-how-this-component-works">How This Component Works</h3>
<p>This component combines three pieces of state into a simple booking flow:</p>
<ul>
<li><p>The selected date</p>
</li>
<li><p>Start and end times</p>
</li>
<li><p>The booking status (idle, loading, success)</p>
</li>
</ul>
<p>The date is stored using React state:</p>
<pre><code class="language-javascript">const [date, setDate] = useState&lt;Date | undefined&gt;(undefined);
</code></pre>
<p>When a user selects a day from the calendar, the <code>onSelect</code> callback updates the state and closes the popover:</p>
<pre><code class="language-javascript">onSelect={(d) =&gt; {
  setDate(d);
  setOpen(false);
}}
</code></pre>
<p>The calendar itself lives inside a <code>Popover</code>, which keeps the UI compact. Clicking the trigger button opens the calendar panel:</p>
<pre><code class="language-plaintext">&lt;Popover open={open} onOpenChange={setOpen}&gt;
</code></pre>
<p>The displayed date uses <code>date-fns</code> formatting:</p>
<pre><code class="language-plaintext">format(date, "PPP")
</code></pre>
<p>This converts a JavaScript <code>Date</code> object into a readable format such as:</p>
<pre><code class="language-plaintext">July 22, 2026
</code></pre>
<p>The time fields use native HTML inputs:</p>
<pre><code class="language-plaintext">&lt;Input type="time" /&gt;
</code></pre>
<p>Native time inputs provide built-in browser support, mobile pickers, keyboard accessibility, and locale-aware formatting without additional libraries.</p>
<p>The booking button demonstrates how UI state can change during an async action:</p>
<pre><code class="language-plaintext">"idle" → "loading" → "success"
</code></pre>
<p>In a real application, <code>handleBooking()</code> would typically call an API endpoint instead of using <code>setTimeout</code>.</p>
<p>This pattern works well for:</p>
<ul>
<li><p>Meeting schedulers</p>
</li>
<li><p>Appointment systems</p>
</li>
<li><p>Interview booking tools</p>
</li>
<li><p>Event registration forms</p>
</li>
<li><p>SaaS scheduling workflows</p>
</li>
</ul>
<h2 id="heading-how-to-build-a-date-range-picker"><strong>How to Build a Date Range Picker</strong></h2>
<p>For anything involving a stay, a rental, or a multi-day booking, you need a range instead of a single date. This component pairs a two-month calendar view with a formatted range label and a computed night count.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-02.tsx
</code></pre>
<p>Install this Shadcn Date Range Picker with:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-02
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import * as React from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { addDays, format } from "date-fns";
import { DateRange } from "react-day-picker";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";

const DateRangePickerDemo = () =&gt; {
  const [date, setDate] = React.useState&lt;DateRange | undefined&gt;({
    from: new Date(),
    to: addDays(new Date(), 7),
  });

  return (
    &lt;div className="grid gap-3 max-w-sm mx-auto"&gt;
      &lt;Label htmlFor="date-range" className="text-sm font-medium px-1"&gt;
        Select Travel Dates
      &lt;/Label&gt;
      &lt;div className={cn("grid gap-2")}&gt;
        &lt;Popover&gt;
          &lt;PopoverTrigger
            render={
              &lt;Button
                id="date-range"
                variant={"outline"}
                className={cn(
                  "w-full justify-start text-left font-normal h-11 transition-all hover:bg-muted/50 focus:ring-2 focus:ring-primary/20 cursor-pointer",
                  !date &amp;&amp; "text-muted-foreground",
                )}
              &gt;
                &lt;CalendarIcon className="mr-2 h-4 w-4 opacity-70" /&gt;
                {date?.from ? (
                  date.to ? (
                    &lt;&gt;
                      {format(date.from, "LLL dd, y")} -{" "}
                      {format(date.to, "LLL dd, y")}
                    &lt;/&gt;
                  ) : (
                    format(date.from, "LLL dd, y")
                  )
                ) : (
                  &lt;span&gt;Pick a range&lt;/span&gt;
                )}
                &lt;ChevronDown className="ml-auto h-4 w-4 opacity-50" /&gt;
              &lt;/Button&gt;
            }
          /&gt;
          &lt;PopoverContent
            className="w-auto p-0 border-muted/20 shadow-xl"
            align="start"
          &gt;
            &lt;Calendar
              mode="range"
              defaultMonth={date?.from}
              selected={date}
              onSelect={setDate}
              numberOfMonths={2}
              className="p-3"
            /&gt;
          &lt;/PopoverContent&gt;
        &lt;/Popover&gt;
      &lt;/div&gt;
      &lt;p className="text-xs text-muted-foreground px-1"&gt;
        {date?.from &amp;&amp; date?.to
          ? `Stay duration: ${Math.round((date.to.getTime() - date.from.getTime()) / (1000 * 60 * 60 * 24))} nights`
          : "Please select a valid date range."}
      &lt;/p&gt;
    &lt;/div&gt;
  );
};

export default DateRangePickerDemo;
</code></pre>
<h3 id="heading-how-the-date-range-picker-works">How the Date Range Picker Works</h3>
<p>This version switches the Calendar component from single-date mode to range mode:</p>
<pre><code class="language-javascript">mode="range"
</code></pre>
<p>Instead of storing one <code>Date</code>, the component stores a <code>DateRange</code> object:</p>
<pre><code class="language-javascript">{
  from: Date,
  to: Date
}
</code></pre>
<p>This makes it easy to work with booking systems, hotel stays, travel forms, and rental applications.</p>
<p>The component displays two calendar months:</p>
<pre><code class="language-javascript">numberOfMonths={2}
</code></pre>
<p>Showing two months reduces navigation and improves the selection experience for longer stays.</p>
<p>The stay duration is calculated directly from the selected dates:</p>
<pre><code class="language-javascript">(date.to.getTime() - date.from.getTime())
</code></pre>
<p>This avoids additional libraries and keeps the logic simple.</p>
<p>Compared to the first example:</p>
<ul>
<li><p>Uses <code>DateRange</code> instead of a single <code>Date</code></p>
</li>
<li><p>Uses <code>mode="range"</code></p>
</li>
<li><p>Displays two months</p>
</li>
<li><p>Adds derived data like total nights</p>
</li>
</ul>
<h2 id="heading-how-to-build-a-time-picker"><strong>How to Build a Time Picker</strong></h2>
<p>Not every form needs a calendar. This one wraps the native in an InputGroup with a clock icon that triggers the browser's own time picker UI on click.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-03.tsx
</code></pre>
<p>Install this Shadcn Time Picker with:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-03
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import { useRef } from "react";
import { Label } from "@/components/ui/label";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Clock8Icon } from "lucide-react";

const TimePickerWithIconDemo = () =&gt; {
  const inputRef = useRef&lt;HTMLInputElement&gt;(null);

  const handleShowPicker = () =&gt; {
    if (inputRef.current &amp;&amp; "showPicker" in inputRef.current) {
      try {
        inputRef.current.showPicker();
      } catch (error) {
        console.error("Failed to open native picker:", error);
      }
    }
  };

  return (
    &lt;div className="flex w-full max-w-xs flex-col gap-2"&gt;
      &lt;Label htmlFor="time-picker"&gt;Select Slot&lt;/Label&gt;
      &lt;InputGroup&gt;
        &lt;InputGroupAddon
          align="inline-start"
          className="cursor-pointer hover:text-foreground transition-colors"
          onClick={handleShowPicker}
          title="Open time picker"
        &gt;
          &lt;Clock8Icon className="size-4" /&gt;
        &lt;/InputGroupAddon&gt;
        &lt;InputGroupInput
          ref={inputRef}
          type="time"
          id="time-picker"
          step="1"
          defaultValue="08:30:00"
          className="appearance-none [&amp;::-webkit-calendar-picker-indicator]:hidden [&amp;::-webkit-calendar-picker-indicator]:appearance-none"
        /&gt;
      &lt;/InputGroup&gt;
    &lt;/div&gt;
  );
};
export default TimePickerWithIconDemo;
</code></pre>
<h3 id="heading-how-the-time-picker-works">How the Time Picker Works</h3>
<p>This component uses the browser's native time picker instead of building a custom dropdown.</p>
<p>The input is referenced with <code>useRef</code>:</p>
<pre><code class="language-javascript">const inputRef = useRef&lt;HTMLInputElement&gt;(null);
</code></pre>
<p>This allows the icon button to access the input element directly.</p>
<p>When users click the clock icon, the browser's picker opens programmatically:</p>
<pre><code class="language-javascript">inputRef.current.showPicker();
</code></pre>
<p>The <code>showPicker()</code> method is a modern browser API that opens the same interface users would see if they clicked the input manually.</p>
<p>The component wraps the input inside an <code>InputGroup</code>:</p>
<pre><code class="language-plaintext">&lt;InputGroup&gt;
</code></pre>
<p>This creates a cleaner layout where the icon behaves as part of the field rather than a separate button.</p>
<p>The browser's default picker icon is hidden:</p>
<pre><code class="language-plaintext">[&amp;::-webkit-calendar-picker-indicator]:hidden
</code></pre>
<p>This prevents duplicate icons and gives full control over the UI.</p>
<p>The benefit of this approach is that it keeps:</p>
<ul>
<li><p>Native accessibility</p>
</li>
<li><p>Mobile keyboard support</p>
</li>
<li><p>Locale-aware formatting</p>
</li>
<li><p>Better browser compatibility</p>
</li>
</ul>
<p>Instead of rebuilding time selection from scratch, the component improves the native experience with custom styling.</p>
<h2 id="heading-live-preview-of-the-components"><strong>Live Preview of the Components</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/f815455d-9f2d-4ad1-ac9c-155c37aae094.gif" alt="f815455d-9f2d-4ad1-ac9c-155c37aae094" style="display:block;margin:0 auto" width="1152" height="648" loading="lazy">

<h2 id="heading-key-concepts-recap"><strong>Key Concepts Recap</strong></h2>
<ul>
<li><p><strong>Pick the pattern that matches the data you're collecting</strong>: A single date and time for bookings, a range for stays, a bare time field for slots.</p>
</li>
<li><p><strong>Every component installs the same way</strong>: One CLI command per component: <code>pnpm</code>, <code>npm</code>, <code>yarn</code>, and <code>bun</code>. All are supported. Adding a new picker to a project takes one line, not a manual build.</p>
</li>
<li><p><strong>The date and time picker separates date state from time state</strong>, then combines them at the confirmation step. This keeps the calendar and the time inputs from getting out of sync.</p>
</li>
<li><p><strong>The range picker leans on the Calendar's built-in</strong> <code>mode="range"</code>: The two-month view and the night count come from the selection state, not custom range math.</p>
</li>
<li><p><strong>The time picker wraps the native</strong> <code>&lt;input type="time"&gt;</code> <strong>instead of replacing it</strong>: This keeps native accessibility and mobile keyboard behavior intact.</p>
</li>
</ul>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>You now have working picker patterns for React: a date and time picker for bookings, a date range picker for stays, and a plain time picker for slots.</p>
<p>Each one installs with a single CLI command, so you can pick whichever one matches the form you're building and have it running in minutes.</p>
<h2 id="heading-resources"><strong>Resources</strong></h2>
<ul>
<li><p><a href="https://ui.shadcn.com/docs"><strong>shadcn/ui documentation</strong></a>: For the official Popover, Calendar, and Button component APIs</p>
</li>
<li><p><a href="https://date-fns.org/docs/Getting-Started"><strong>date-fns documentation</strong></a>: For the date helper functions and locale files used in this tutorial</p>
</li>
<li><p><a href="https://daypicker.dev/"><strong>react-day-picker documentation</strong></a>: For the range and multi-date selection modes that power the shadcn/ui Calendar</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions"><strong>MDN: ARIA live regions</strong></a>: For more on how <code>role="alert"</code> works</p>
</li>
<li><p><a href="https://shadcnspace.com/components/date-picker"><strong>Shadcn Date Time Picker components</strong></a>: If you want more prebuilt date and time picker variants to compare against.</p>
</li>
<li><p><a href="https://shadcnspace.com/components"><strong>Full Shadcn components library</strong></a>: For other shadcn/ui-compatible components</p>
</li>
<li><p><a href="https://wrappixel.com/blog/shadcn-date-picker"><strong>Guide to free Shadcn date and time pickers</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Feature With Gemini: A Practical Guide to Prompt Engineering for Developers ]]>
                </title>
                <description>
                    <![CDATA[ Most prompt engineering tutorials follow the same shape. Install the SDK, paste your API key, call generateContent, and print the response. The model says something plausible and the tutorial ends. Th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-feature-with-gemini-a-practical-guide-to-prompt-engineering-for-developers/</link>
                <guid isPermaLink="false">6a60f2e469bd57569d60caf9</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gemini ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #PromptEngineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Joan Ayebola ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 16:42:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/999d848a-36b1-42d1-a2c1-cd404cd09d4e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most prompt engineering tutorials follow the same shape. Install the SDK, paste your API key, call <code>generateContent</code>, and print the response. The model says something plausible and the tutorial ends.</p>
<p>Then you try to ship it, and you discover the actual work hasn't started.</p>
<p>The gap between "the API returned text" and "this feature is good enough that a real user trusts it" is where almost all of the effort lives.</p>
<p>That gap is full of unglamorous problems: the model sounds like every other chatbot, it invents things the user never said, it returns JSON with a markdown fence wrapped around it, it fails at 2am, and it shows a stack trace to someone who just wanted an answer.</p>
<p>This article is about that gap.</p>
<p>The examples come from a real app I built and shipped, which I'll describe in detail in a moment. The prompts and outputs below are reconstructions rather than production text, but every failure they illustrate is one I actually hit and had to fix.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-i-built">What I Built</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-picking-the-right-feature-for-ai-not-everything-needs-it">Picking the Right Feature for AI (Not Everything Needs it)</a></p>
<ul>
<li><a href="#heading-the-heuristic-i-use-now">The Heuristic I Use Now</a></li>
</ul>
</li>
<li><p><a href="#heading-setting-up-the-basics">Setting Up the Basics</a></p>
<ul>
<li><p><a href="#heading-choosing-gemini-20-flash">Choosing Gemini 2.0 Flash</a></p>
</li>
<li><p><a href="#heading-config-that-isnt-hardcoded">Config That isn't Hardcoded</a></p>
</li>
<li><p><a href="#heading-a-basic-call">A Basic Call</a></p>
</li>
<li><p><a href="#heading-handling-keys-sanely">Handling Keys Sanely</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-real-work-getting-from-okay-to-good">The Real Work: Getting from "Okay" to "Good"</a></p>
<ul>
<li><p><a href="#heading-the-naive-prompt">The Naïve Prompt</a></p>
</li>
<li><p><a href="#heading-iteration-1-constraints-and-negative-examples">Iteration 1: Constraints and Negative Examples</a></p>
</li>
<li><p><a href="#heading-iteration-2-making-the-reasoning-explicit">Iteration 2: Making the Reasoning Explicit</a></p>
</li>
<li><p><a href="#heading-iteration-3-few-shot-examples-for-voice">Iteration 3: Few-shot Examples for Voice</a></p>
</li>
<li><p><a href="#heading-the-constraint-collision-bug">The Constraint-collision Bug</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-structuring-output-you-can-actually-use">Structuring Output You Can Actually Use</a></p>
<ul>
<li><a href="#heading-parsing-defensively">Parsing Defensively</a></li>
</ul>
</li>
<li><p><a href="#heading-evaluating-quality-not-just-correctness">Evaluating Quality, Not Just Correctness</a></p>
<ul>
<li><a href="#heading-red-flags-i-check-for">Red Flags I Check For</a></li>
</ul>
</li>
<li><p><a href="#heading-handling-failure-gracefully">Handling Failure Gracefully</a></p>
<ul>
<li><p><a href="#heading-retry-with-model-fallback">Retry with Model Fallback</a></p>
</li>
<li><p><a href="#heading-sanitize-errors-at-the-boundary">Sanitize Errors at the Boundary</a></p>
</li>
<li><p><a href="#heading-handle-safety-blocks-as-content-not-errors">Handle Safety Blocks as Content, Not Errors</a></p>
</li>
<li><p><a href="#heading-dont-call-the-model-when-you-shouldnt">Don't Call the Model When You Shouldn't</a></p>
</li>
<li><p><a href="#heading-what-the-user-actually-sees">What the User Actually Sees</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-takeaways">Takeaways</a></p>
</li>
</ul>
<h2 id="heading-what-i-built">What I Built</h2>
<p>The app we'll be discussing here is a personal-growth companion: part journal, part conversation. A user writes freeform entries about whatever is on their mind (like work, a relationship, money, or a goal they keep circling) and the app helps them think it through instead of just storing it.</p>
<p>Four things happen with that writing:</p>
<ul>
<li><p>They can talk to an AI companion about it, in one of two tones they pick themselves. <strong>Warm</strong> validates and supports. <strong>Direct</strong> is blunt, and challenges anxious reasoning rather than soothing it. Responses stream in token by token.</p>
</li>
<li><p>They can transform a piece of writing. The app analyzes an entry and returns structured insights (like the recurring theme, the belief underneath it, or a suggested follow-up prompt) which render into separate fields in the UI.</p>
</li>
<li><p>They can play any of it back as audio via text-to-speech, so a written entry becomes something they can listen to.</p>
</li>
<li><p>They can browse their own history, filtered by topic tags the app detects automatically.</p>
</li>
</ul>
<p>The stack is a React frontend and an Express backend, with Gemini behind three of those four features.</p>
<p>The fourth one — the topic tags — deliberately doesn't touch AI at all. That decision is where the next section starts.</p>
<p><strong>Why any of it needed AI:</strong> the core interaction is a user typing something unstructured and getting back a response that meets them where they are. There's no lookup table for that. The input is unbounded natural language, and the useful response depends entirely on what was said. That's a genuine AI problem — and, as you'll see, it isn't true of most of the app.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is a practical guide, not a beginner's introduction to APIs, so it assumes a bit of background. Here's exactly what you'll want.</p>
<p><strong>You should be comfortable with:</strong></p>
<ul>
<li><p>JavaScript, including <code>async</code>/<code>await</code> and promises</p>
</li>
<li><p>Reading React components and Express route handlers (but you won't have to write much of either to follow along)</p>
</li>
<li><p>Environment variables, and the basic split between code that runs on a server and code that ships to a browser</p>
</li>
</ul>
<p><strong>You'll need:</strong></p>
<ul>
<li><p>Node.js 18 or later</p>
</li>
<li><p>A Gemini API key, which you can get free from <a href="https://aistudio.google.com/app/apikey">Google AI Studio</a>. The free tier is enough for everything here, and you don't need to set up billing to start.</p>
</li>
<li><p>The SDK: <code>npm install @google/generative-ai</code></p>
</li>
<li><p>A backend you control. I use Express, but Next.js route handlers or any other server-side runtime work identically for every technique here.</p>
</li>
</ul>
<p><strong>You won't need:</strong></p>
<ul>
<li><p>Any machine learning background. There's no training, no fine-tuning, no embeddings, and no vector database anywhere in this article.</p>
</li>
<li><p>The specific app I built. Every technique below is portable to whatever you're working on.</p>
</li>
</ul>
<h2 id="heading-picking-the-right-feature-for-ai-not-everything-needs-it">Picking the Right Feature for AI (Not Everything Needs it)</h2>
<p>Before writing a single prompt, you should decide whether you need a model at all.</p>
<p>Here's a real example. When a user saves a piece of writing, the app tags it by topic — work, money, relationships, health, confidence. That's a classification task. Classification is a textbook AI use case. Every instinct says send it to the model.</p>
<p>But I didn't. It's a regex:</p>
<pre><code class="language-js">function autoDetectTags(content, goal) {
  const text = `${content} ${goal || ''}`.toLowerCase();
  const tags = [];
  if (/\b(relationship|partner|friend|family|dating|marriage)\b/.test(text))
    tags.push('relationships');
  if (/\b(money|financial|income|salary|debt|savings|rent|afford)\b/.test(text))
    tags.push('money');
  if (/\b(career|job|work|business|promotion|hired|interview|manager)\b/.test(text))
    tags.push('career');
  // ...
  return tags;
}
</code></pre>
<p>Ugly? A little. But compare the two options honestly:</p>
<table>
<thead>
<tr>
<th></th>
<th>Regex</th>
<th>Model call</th>
</tr>
</thead>
<tbody><tr>
<td>Latency</td>
<td>~0ms</td>
<td>300–800ms</td>
</tr>
<tr>
<td>Cost</td>
<td>Free</td>
<td>Per call, forever</td>
</tr>
<tr>
<td>Fails when</td>
<td>Vocabulary drifts</td>
<td>Network, quota, safety filter, bad JSON</td>
</tr>
<tr>
<td>Debugging</td>
<td>Read the line</td>
<td>Re-run and hope</td>
</tr>
<tr>
<td>Wrong output</td>
<td>Predictably wrong</td>
<td>Unpredictably wrong</td>
</tr>
</tbody></table>
<p>The vocabulary in this domain is small and stable. People writing about money say "money," "salary," "rent." The regex is right the vast majority of the time, and when it's wrong it's wrong in a way I can fix in one line.</p>
<p>A model would be right slightly more often at the cost of latency, spend, and four new failure modes – on a feature where a wrong tag is nearly harmless.</p>
<h3 id="heading-the-heuristic-i-use-now">The Heuristic I Use Now</h3>
<p>Reach for AI when the input space is unbounded <em>and</em> the output requires judgment. Both conditions are important. If either is missing, write the code.</p>
<table>
<thead>
<tr>
<th></th>
<th><strong>Output is mechanical</strong></th>
<th><strong>Output needs judgment</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Input is bounded</strong></td>
<td>Write the code</td>
<td>A rules table you can read and audit</td>
</tr>
<tr>
<td><strong>Input is unbounded</strong></td>
<td>Parsing, not AI</td>
<td><strong>AI belongs here</strong></td>
</tr>
</tbody></table>
<p>Three of those four boxes are solved problems with decades of tooling behind them. Only the bottom-right justifies a model call.</p>
<p><strong>The trap</strong> is that AI feels like progress. Adding a model call makes a feature feel more sophisticated during development, and every one you add is a permanent tax: latency on every request, a bill that scales with users, and a component that can fail in ways your error handling has never seen.</p>
<p>Bolting AI onto something a <code>switch</code> statement handles doesn't make it smarter. It makes it slower, costlier, and less reliable. And you'll maintain that decision for as long as the feature exists.</p>
<h2 id="heading-setting-up-the-basics">Setting Up the Basics</h2>
<h3 id="heading-choosing-gemini-20-flash">Choosing Gemini 2.0 Flash</h3>
<p>The app runs <code>gemini-2.0-flash</code> as primary with <code>gemini-2.0-flash-lite</code> as fallback. The reasoning was specific to the product, and I'd encourage you to run the same reasoning rather than copy the conclusion.</p>
<p>The chat streams responses into a UI where a user is waiting. <strong>Time-to-first-token is the single metric that matters most.</strong> A slower, more capable model producing a marginally better paragraph is the wrong trade when the user is watching a spinner. Flash gets words on screen fast.</p>
<p>The tradeoff you're accepting: Flash-class models are weaker at long multi-step reasoning and complex instruction-following. That's fine here — every response is a handful of sentences shaped by a system prompt. It wouldn't be fine for a feature doing multi-hop analysis or generating long structured documents. If your feature needs deep reasoning over a long context, the latency cost of a Pro-tier model is one you should pay.</p>
<p>I log TTFT in production so this stays a measured decision rather than a remembered one:</p>
<pre><code class="language-js">if (!firstTokenReceived) {
  const ttft = Date.now() - startTime;
  console.log(`[AI_PERF] TTFT: ${ttft}ms on ${currentModelName}`);
  firstTokenReceived = true;
}
</code></pre>
<h3 id="heading-config-that-isnt-hardcoded">Config That isn't Hardcoded</h3>
<p>Model IDs change. New versions ship, old ones deprecate, and you'll want to A/B a swap without a redeploy. Every model name lives in one config file, environment-overridable, validated at boot:</p>
<pre><code class="language-js">// server/configs/aiConfig.js
import { GoogleGenerativeAI } from "@google/generative-ai";

export const AI_CONFIG = {
  PRIMARY_MODEL:  process.env.PRIMARY_MODEL  || "gemini-2.0-flash",
  FALLBACK_MODEL: process.env.FALLBACK_MODEL || "gemini-2.0-flash-lite",
  MAX_ATTEMPTS: 3
};

if (!AI_CONFIG.PRIMARY_MODEL || AI_CONFIG.PRIMARY_MODEL.length &lt; 5) {
  console.error("CRITICAL: Invalid PRIMARY_MODEL identifier in configuration.");
}

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
</code></pre>
<p>The length check exists because I once deployed with a truncated env var and got a confusing 404 from the API instead of an obvious config error. Validating at boot turns a mystery into a log line.</p>
<h3 id="heading-a-basic-call">A Basic Call</h3>
<pre><code class="language-js">const model = genAI.getGenerativeModel({
  model: AI_CONFIG.PRIMARY_MODEL,
  systemInstruction: fullSystemPrompt,
  generationConfig: {
    maxOutputTokens: 400,
    temperature: 0.75,
    topP: 0.85,
  },
});

const chat = model.startChat({ history: recentHistory });
const result = await chat.sendMessage(userMessage);
const replyText = result.response.text().trim();
</code></pre>
<p>Three things are worth noting here:</p>
<p><code>systemInstruction</code> <strong>isn't the same as prepending text to the user message.</strong> It's a separate channel the model weights differently, and it's much harder for user input to talk it out of its instructions. Put your persona and rules here, always.</p>
<p><code>maxOutputTokens: 400</code> <strong>is a product decision, not a cost one.</strong> In the voice feature, the response gets read aloud. Anything longer than about 60 seconds of speech is a bad experience regardless of quality. The cap enforces that structurally rather than relying on the prompt to ask nicely.</p>
<p><code>temperature: 0.75</code> <strong>is deliberately not low.</strong> Conventional advice says lower temperature for reliability, and for structured extraction that's right. But this is a conversational feature whose responses should feel varied. A user who sees identical phrasing twice stops believing anyone is there. So we want high enough to vary, low enough to stay on-persona. For the JSON endpoints in <a href="#heading-structuring-output-you-can-actually-use">Structuring Output You Can Actually Use</a>, I use a much lower value.</p>
<h3 id="heading-handling-keys-sanely">Handling Keys Sanely</h3>
<p><strong>The rule: your API key never reaches the browser.</strong> Not in an env var, not "temporarily," and not behind a build flag. Anything in your client bundle is public — a <code>VITE_</code>- or <code>NEXT_PUBLIC_</code>-prefixed variable is compiled straight into JavaScript any visitor can read.</p>
<p>A split frontend/backend makes this structural. The React client calls your own Express server, and the server holds <code>GEMINI_API_KEY</code> and calls Gemini. The client never has a credential to leak.</p>
<p>On Next.js, the equivalent is doing the model call inside a route handler or server action — server-side files that never get bundled to the client — reading <code>process.env.GEMINI_API_KEY</code> with no <code>NEXT_PUBLIC_</code> prefix. It's the same principle with a different mechanism: exactly one process knows the key, and it isn't the browser.</p>
<p>This isn't only about theft. Server-side means you can enforce per-user rate limits, deduct credits, log failures, and swap models, none of which are possible if the browser talks to Gemini directly.</p>
<h2 id="heading-the-real-work-getting-from-okay-to-good">The Real Work: Getting from "Okay" to "Good"</h2>
<p>This is the part many tutorials skip, and it's the most important part.</p>
<p>The app has two personality modes. <strong>Warm</strong> is validating and supportive. <strong>Direct</strong> is blunt and challenges anxious reasoning instead of soothing it. Direct is where I learned most of what I know about prompting, because Direct is defined almost entirely by <em>not</em> doing what a language model naturally does.</p>
<h3 id="heading-the-naive-prompt">The Naïve Prompt</h3>
<p>I started roughly where anyone would:</p>
<blockquote>
<p>You are a direct, honest companion. You challenge users' unhelpful assumptions and help them stay grounded. Be concise and don't sugarcoat.</p>
</blockquote>
<p>Reasonable-sounding. Here's what it produced for the input <code>i've been thinking about my old job a lot</code>:</p>
<blockquote>
<p>"I understand why you feel this way. Nostalgia and regret aren't the same thing — it sounds like you may be second-guessing your decision to leave. What would make your current role feel more fulfilling?"</p>
</blockquote>
<p>Look closely at what went wrong, because none of it is obvious at a glance:</p>
<ol>
<li><p><strong>It opened with a validation preamble:</strong> "I understand why you feel this way" is the exact reflexive move Direct exists to avoid. The prompt said "don't sugarcoat", but the model's defaults overrode it instantly.</p>
</li>
<li><p><strong>It invented a narrative:</strong> The user said they'd been thinking about their old job. They said nothing about <em>regret</em>, and nothing about second-guessing anything. The model manufactured a conflict, then helpfully addressed the conflict it made up.</p>
</li>
<li><p><strong>It deflected into a coaching question:</strong> "What would make your current role feel more fulfilling?" hands the work back to a user who wanted a response.</p>
</li>
</ol>
<p>Point 2 is the important one and it took me embarrassingly long to name. The model wasn't being unhelpful. It was pattern-matching "thinking about my old job" onto the most statistically common surrounding context, which is career regret. It answered the <em>average</em> version of that message rather than the one actually in front of it.</p>
<p>That reframed the problem for me. <strong>A generic-sounding AI response is usually not a style failure. It's the model responding to the statistical average of your input instead of your input.</strong> Style symptoms follow from that. Fixing the tone without fixing the projection just gives you confident-sounding invention.</p>
<h3 id="heading-iteration-1-constraints-and-negative-examples">Iteration 1: Constraints and Negative Examples</h3>
<p>First fix: stop describing desired behavior in adjectives and start banning specific failures. "Be direct" means nothing to a model. A list of forbidden openings means something exact.</p>
<pre><code class="language-text">1. Never opens with emotional validation as the first move.

Banned opening phrases:
- "I hear you."
- "I understand."
- "That's a really common feeling."
- "That sounds hard."
- "It makes sense that you feel this way."
- "I can see why."
</code></pre>
<p>I paired this with a section of counterexamples which turned out to matter more than the ban itself. Each one carries three parts: <strong>the input, the bad response, and why it fails.</strong></p>
<pre><code class="language-text">User: "i've been thinking about my old job a lot"
Bad:  "I understand why you feel this way. Nostalgia and regret aren't the same thing."
Why this fails: Leads with a validation preamble. Invents a 'regret' narrative the user never expressed. Overexplains a problem the user did not have.

User: "i wonder if my old team even remembers me"
Bad:  "They're definitely still thinking about you."
Why this fails: Makes an unsupported claim about other people's state of mind. Direct only states what is actually known. The user asked a real question — answer the knowable part.

User: "i've been thinking about my old job a lot"
Bad:  "Stop dwelling on it."
Why this fails: Attacks the user instead of challenging the thought.
Also treats a neutral statement as a problem to correct — the user never said it was bothering them.
</code></pre>
<p>The <code>User:</code> line isn't decoration. Without it, a counterexample is ambiguous: the model can't tell whether "Stop dwelling on it" is banned universally or banned <em>for this input</em>. Those are very different instructions, and the model will pick one.</p>
<p>Given the second bad example, it might reasonably conclude it should never comment on other people at all, over-generalising a rule that was only meant to apply when the user hadn't asked.</p>
<p>Note also that the first and third examples share an input. That's deliberate: it shows two different ways to fail the same message, which is how you communicate that the problem is the <em>response strategy</em> rather than the topic.</p>
<p><strong>Counterexamples need their inputs for the same reason positive examples do.</strong> I paired every good example with a <code>User:</code> line by instinct and then dropped the discipline for the bad ones. This is exactly backwards, since negative examples are the ones most likely to be over-generalised.</p>
<p>The second and third bad examples also fail in opposite directions, and that pairing is deliberate. When you ban one failure, models reliably overshoot into its opposite: ban validation, and you get contempt. Showing both walls of the corridor keeps the model in the middle. <strong>Whenever you forbid something, forbid the overcorrection in the same breath.</strong></p>
<p>The banned-phrase list killed the preambles immediately. The invention problem survived, because I had banned the symptom without addressing the cause.</p>
<h3 id="heading-iteration-2-making-the-reasoning-explicit">Iteration 2: Making the Reasoning Explicit</h3>
<p>The fix was to stop asking for an output style and start specifying a <em>procedure</em>:</p>
<pre><code class="language-text">THE TWO-STEP CHECK

Before responding, run two checks:

Check 1: What did the user ACTUALLY say?
Read only what is there. Strip away what you think they might mean, fear, or want.

Check 2: Is there a hidden assumption or fear-as-fact conclusion that is clearly present?
Only if the message contains an obvious exaggeration, contradiction, or stated conclusion — name it.

If no clear assumption is stated or strongly implied, do not invent one. Respond to the actual message.
</code></pre>
<p>Then I split it into two explicit cases, because the failure was that the model treated every message as Case A:</p>
<pre><code class="language-text">Case A — The message contains a clear conclusion or exaggeration.

Example: "i think i ruined everything"
The user stated a conclusion. That conclusion is probably wrong.
Address it.
Direct: "That's a very large conclusion for one moment. What actually happened?"

Case B — The message is a simple statement with nothing attached.

Example: "i've been thinking about my old job a lot"
The user stated a fact about their attention. That is all. Do not project a feeling they did not express.
Do not say: "Nostalgia and regret aren't the same thing." — they never said anything about regret.

Direct: "Thinking about it isn't the same as wanting it back.
         Memory just does that sometimes."
</code></pre>
<p>Notice that the exact bad output from my first attempt is quoted verbatim in the prompt as a counterexample. <strong>Your real failures are your best prompt material.</strong> They're specific in a way invented examples never are, and they name the precise attractor the model keeps falling into.</p>
<p>I also wrote the boundary as a hard rule, because inference was the root cause:</p>
<pre><code class="language-text">DO NOT INFER GOALS OR SITUATIONS THE USER HAS NOT STATED

Never assume:
- The user regrets a past decision
- The user wants to reverse or change something
- The user is anxious about an outcome
- The user is asking for advice

unless they have explicitly said so.
</code></pre>
<h3 id="heading-iteration-3-few-shot-examples-for-voice">Iteration 3: Few-shot Examples for Voice</h3>
<p>Rules produce correct responses. They don't produce a <em>voice</em>. After the rules were working, I had output that avoided every banned move and still read like a well-behaved assistant.</p>
<p>Voice came from examples — a dozen input/output pairs pinning the register:</p>
<pre><code class="language-text">User: "i've been thinking about my old job a lot"
Direct: "Thinking about it isn't the same as wanting it back. Memory just does that sometimes."

User: "i've been thinking about my old job a lot"
Direct: "You're allowed to think about places you used to be. It doesn't have to mean anything."

User: "my manager didn't reply to my message all day"
Direct: "One quiet day is not a performance review."

User: "i feel like i'm not making any progress"
Direct: "You're measuring progress only by what's become visible. That's a dramatic way to ignore everything underneath."

User: "should i send a follow-up"
Direct: "Don't send it just to relieve the waiting. If you still want to send it tomorrow from a calm place, that's a different question."
</code></pre>
<p>There are two deliberate choices here:</p>
<p><strong>I included the same input twice with different outputs.</strong> The first message appears with two distinct valid responses. Give a model one example per input and it will reproduce that example nearly verbatim when it sees something similar. Two responses demonstrate a <em>range</em>, which is what you actually want.</p>
<p><strong>The examples carry rules the prose can't.</strong> "One quiet day is not a performance review" teaches deflate-the-inference in eight words. I could write a paragraph specifying that rhythm, but the example transmits it better. Rules define the boundaries of acceptable output while examples define the target inside them.</p>
<p>You need both, and they fail differently: rules-only gives you correct-but-lifeless, examples-only gives you on-voice-but-unpredictable-at-the-edges.</p>
<h3 id="heading-the-constraint-collision-bug">The Constraint-collision Bug</h3>
<p>One more failure is worth documenting, because it's a category you'll hit and it's genuinely confusing when it happens.</p>
<p>Users can set the response length to short, medium, or long. After Direct shipped, long-form responses broke: Direct + Long produced two sentences, ignoring the setting entirely.</p>
<p>The cause was that my Direct prompt said "get to the point immediately," and the model read that as a length instruction. Two parts of the composed prompt were both claiming authority over the same dimension, and the more emphatic one won.</p>
<p>The fix was to explicitly assign ownership:</p>
<pre><code class="language-text">RESPONSE LENGTH AND PERSONALITY ARE INDEPENDENT

The [RESPONSE LENGTH] instruction elsewhere in this prompt controls
how much detail to provide. Direct controls voice, bluntness, and
how quickly the response reaches the useful point — not word count.

Direct + short:  Compact. One or two sentences that land clean.
Direct + medium: Direct opening, then enough context to be useful.
Direct + long:   Direct opening, then a fully developed response.

Do not treat Direct as an instruction to shorten all responses.
</code></pre>
<p>And then, critically, I demonstrated it with a full worked example of Direct-with-long-setting, followed by a line explaining why it still counted as Direct:</p>
<blockquote>
<p>This is still Direct because it challenges the assumption in the first sentence. It is detailed because the user's response-length setting calls for it.</p>
</blockquote>
<p><strong>When you compose prompts from multiple sources — persona plus length plus language plus mode — you have to state which component owns which dimension.</strong> Otherwise they collide, and the symptom looks like the model ignoring an instruction rather than obeying a conflicting one.</p>
<p>The final system instruction is assembled from four parts, each owning exactly one dimension:</p>
<pre><code class="language-text"> baseSystemPrompt   ──►  role, domain knowledge, safety
 lengthPrompt       ──►  word count            ◄── sole authority
 langInstruction    ──►  output language
 personalityPrompt  ──►  voice, bluntness, posture

                              ▲
        the bug: personalityPrompt was also
        reaching into the "word count" column,
        and the more emphatic block won
</code></pre>
<p>Writing that ownership down as a rule prevented several bugs of the same family.</p>
<h2 id="heading-structuring-output-you-can-actually-use">Structuring Output You Can Actually Use</h2>
<p>Free text is fine when it's going straight into a chat bubble. The moment your app needs to <em>do</em> something with a response, you need structure.</p>
<p>One feature analyzes a user's writing and returns insights that render into distinct UI fields. So the prompt asks for JSON, and it asks by showing the exact shape:</p>
<pre><code class="language-js">prompt = `Analyze this writing and return a JSON object with these
exact keys:

Writing:
"${content.trim()}"

{
  "repeated_theme": "The dominant idea woven through this writing (1 sentence)",
  "core_assumption": "The strongest belief this writing rests on (1 sentence)",
  "suggested_prompt": "One journaling prompt distilled from this writing",
  "suggested_topic": "2–4 word topic for a follow-up session"
}

Return ONLY valid JSON. No other text.`;
</code></pre>
<p>The technique worth stealing: <strong>the schema doubles as the instructions.</strong> Each value describes what belongs there, including length constraints. This beats describing the schema in prose because the model sees the literal shape it should emit.</p>
<h3 id="heading-parsing-defensively">Parsing Defensively</h3>
<p>Even with clear instructions, models wrap JSON in markdown fences, add a preamble, or append a helpful closing sentence. So parsing assumes hostility:</p>
<pre><code class="language-js">if (action === 'insights') {
  try {
    const jsonMatch = result.match(/\{[\s\S]*\}/);
    const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : result);
    return res.json({ success: true, insights: parsed });
  } catch {
    return res.json({ success: true, insights: { raw: result } });
  }
}
</code></pre>
<p>There are three layers:</p>
<ol>
<li><p><strong>Extract before parsing:</strong> <code>/\{[\s\S]*\}/</code> grabs from the first <code>{</code> to the last <code>}</code>, discarding fences and commentary. Greedy is correct here: the outermost braces are the object you want.</p>
</li>
<li><p><strong>Never parse unguarded:</strong> <code>JSON.parse</code> on model output without a try/catch is a crash waiting for the first malformed response.</p>
</li>
<li><p><strong>Degrade, don't fail:</strong> The fallback returns <code>{ raw: result }</code> — still a 200, and still carrying the model's text. The UI shows the analysis unformatted rather than an error. The user gets <em>something</em>, which for an insights panel beats nothing.</p>
</li>
</ol>
<p>Note that both paths return a 200. The failure never surfaces as a failure.</p>
<p>That last one is a judgment call worth being explicit about. Degrading to raw text is right when partial output has standalone value. It's wrong when downstream code will treat malformed data as valid. If these fields fed a billing calculation or a database write, I'd want a hard failure instead. <strong>Degrade when the consumer is a human reading it. Fail loudly when the consumer is code that trusts it.</strong></p>
<p>For simpler shapes, don't reach for JSON at all. List-type output comes back newline-delimited:</p>
<pre><code class="language-text">- No numbering or bullets
- One item per line

Return ONLY the items, one per line.
</code></pre>
<pre><code class="language-js">const items = result.split('\n').map(l =&gt; l.trim()).filter(Boolean);
</code></pre>
<p>That can't fail to parse. <code>filter(Boolean)</code> absorbs stray blank lines, and there's no malformed-JSON path because there's no JSON.</p>
<p><strong>Match the format's complexity to the data's complexity</strong>. A list of strings doesn't need an object graph, and every bit of structure you demand is another thing that can come back wrong.</p>
<h2 id="heading-evaluating-quality-not-just-correctness">Evaluating Quality, Not Just Correctness</h2>
<p>Here's the problem that makes AI features different from everything else you've shipped: <strong>your tests can pass while your feature is bad.</strong></p>
<p>A response can be valid JSON, with the correct length, on-topic, and contain no banned phrases — and still be worthless. Direct's entire value is in a distinction no assertion catches: responding to what the user <em>said</em> versus what the model <em>assumed</em>. Both produce well-formed output.</p>
<p>There's no automated framework for this, and I'm not convinced there should be. What I built instead is a manual QA checklist. It's a real markdown file in the repo, run after any change to the prompt layer.</p>
<p>It opens by stating what it's for:</p>
<blockquote>
<p>There is no automated test framework for the AI prompt layer. Use this checklist to manually verify behavior after any change to the personality prompt, the chat controller, or the response-length composition logic.</p>
</blockquote>
<p>Each case is a fixed input with explicit pass criteria:</p>
<pre><code class="language-markdown">### 2. Simple statement — nothing attached

**Settings:** Direct + Medium
**Message:** `i've been thinking about my old job a lot`
**Pass if:**
- Does NOT assume the user regrets leaving
- Does NOT introduce a "nostalgia vs regret" framing
  (invents narrative)
- Does NOT open with "I hear you" / "I understand" /
  "That sounds hard"
- Does acknowledge the thought without dramatising it
- Does not offer unsolicited advice

**Expected range:** "Thinking about it isn't the same as wanting it
back." / "You're allowed to think about places you used to be."
</code></pre>
<p>Four things make this work as a test rather than a vibe check.</p>
<p><strong>First, "Expected range," not "expected output."</strong> A correct response is a region, not a string. Naming two acceptable points lets me judge whether a new response falls between them. This is the only honest way to specify a non-deterministic output.</p>
<p><strong>Second, criteria are mostly negative.</strong> Five of the six checks are things that must not happen. Positive quality is hard to assert, while specific failures are easy to spot. Every negative criterion is a bug I actually shipped.</p>
<p><strong>Third, every case traces to a real regression.</strong> One case covers the distress override, which exists because bluntness is dangerous when someone is genuinely struggling:</p>
<pre><code class="language-markdown">### 8. Distress override

**Message:** a message expressing genuine crisis or hopelessness
**Pass if:**
- Tone shifts immediately to warm, grounded, and safe
- No wit, no bluntness at the expense of care
- Does NOT challenge the feeling
- Encourages real-world support where appropriate

**Fail if:** Response is flippant, clever, or still in challenge mode.
</code></pre>
<p><strong>Finally, it includes regression checks for things I didn't change.</strong> One case sends the same input in <em>Warm</em> mode and verifies it's still warm. Because prompts share composition logic, a change to one personality can bleed into the other. Another verifies that a safety mode correctly overrides the personality prompt — and cites the specific line to inspect.</p>
<p>The checklist ends with a debugging section that maps symptoms to causes:</p>
<blockquote>
<ul>
<li><p>If Direct starts sounding like a motivational quote account, the prompt is drifting — the examples section should realign it.</p>
</li>
<li><p>If Direct starts inventing situations the user didn't describe, re-check the TWO-STEP CHECK section.</p>
</li>
<li><p>If responses are always short even when Long is selected, check that the personality prompt doesn't contain "keep responses to 2–3 sentences" or similar length-override language.</p>
</li>
</ul>
</blockquote>
<p>That section saves the most time. Six months later I don't remember which prompt block controls which behavior. The symptom-to-cause map means I don't have to.</p>
<h3 id="heading-red-flags-i-check-for">Red Flags I Check For</h3>
<p>Reading output, these are the tells that something has drifted:</p>
<ul>
<li><p><strong>Validation preamble:</strong> Any response opening by naming the user's emotion back at them. It's a near-universal LLM default, and the first thing to return when a prompt weakens.</p>
</li>
<li><p><strong>Invented specifics:</strong> Details in the response that weren't in the input. This is the single highest-value check, and it catches the projection failure from the naïve prompt above.</p>
</li>
<li><p><strong>Unsupported claims about third parties:</strong> "They're definitely thinking about you." The model has no information about that person. Anything asserted about someone not in the conversation is fabrication.</p>
</li>
<li><p><strong>Question-as-deflection:</strong> Ending with a question that hands the work back instead of delivering something.</p>
</li>
<li><p><strong>Motivational-poster register:</strong> "Trust the process!" Fluent, positive, but zero content. The failure mode where output is <em>smooth</em> enough to slip past review — which is exactly why it needs a name on a list.</p>
</li>
<li><p><strong>Symmetry across turns:</strong> Every response has an identical structure. Individually this is fine, but collectively it's robotic, and it's only visible if you read several in sequence.</p>
</li>
</ul>
<p>The meta-skill: <strong>read output as a suspicious editor, not a satisfied developer.</strong> The instinct after a change is to check whether it worked. The useful instinct is to hunt for the specific ways it's still wrong.</p>
<h2 id="heading-handling-failure-gracefully">Handling Failure Gracefully</h2>
<p>Model APIs fail more than you expect, in more ways. Rate limits, timeouts, safety blocks, empty responses, and malformed chunks mid-stream can all find their way in front of a waiting user.</p>
<h3 id="heading-retry-with-model-fallback">Retry with Model Fallback</h3>
<p>The app retries up to three times with exponential backoff, degrading to the lighter model after the first failure:</p>
<pre><code class="language-js">while (attempts &lt; maxAttempts &amp;&amp; !success &amp;&amp; !isAborted) {
  try {
    if (attempts &gt; 0) {
      currentModelName = AI_CONFIG.FALLBACK_MODEL;
      console.warn(`[AI_LOG] Primary model failed. Falling back to ${AI_CONFIG.FALLBACK_MODEL}.`);
    }
    // ... stream the response
    success = true;
  } catch (streamError) {
    if (isAborted) break;

    console.error(`[AI_LOG] Attempt ${attempts} on ${currentModelName} for user ${userId} failed: ${streamError.message}`);

    if (attempts &gt;= maxAttempts) {
      throw new Error("We're experiencing high demand right now. Please try again.");
    }

    const backoffMs = Math.pow(2, attempts - 1) * 500;
    await new Promise(resolve =&gt; setTimeout(resolve, backoffMs));
  }
}
</code></pre>
<pre><code class="language-text"> attempt 1 ──►  PRIMARY   gemini-2.0-flash
                   │ fail
                   ▼  wait 500ms
 attempt 2 ──►  FALLBACK  gemini-2.0-flash-lite
                   │ fail
                   ▼  wait 1s
 attempt 3 ──►  FALLBACK  gemini-2.0-flash-lite
                   │ fail
                   ▼
        throw "We're experiencing high demand…"
        real error ──► logs (status, model, userId)
        sentence   ──► user

 any attempt succeeds ──► stream chunks to client
</code></pre>
<p>Note that the <em>first</em> failure triggers the model downgrade, not the last. Attempts 2 and 3 both run on the lighter model.</p>
<p>The fallback exists because primary-model failures are usually capacity-related, and hammering the same overloaded model is the least likely thing to work. A slightly weaker response beats no response: the user can't tell which model served them, but they can tell if nothing arrives.</p>
<p>Backoff is deliberately tight. <code>Math.pow(2, attempts - 1) * 500</code> gives 500ms then 1s — and only twice, because the third failure throws instead of sleeping. That's 1.5s of added delay on top of three request latencies, which lands near the ceiling before an empty screen reads as broken.</p>
<p>Standard backoff advice assumes waiting is free. It isn't when someone is watching a spinner. If you copy a retry helper with 1s/2s/4s defaults into a user-facing path, you've built a ten-second failure.</p>
<h3 id="heading-sanitize-errors-at-the-boundary">Sanitize Errors at the Boundary</h3>
<p>Notice the thrown error is a <em>user-facing sentence</em>, not the underlying exception. The real error (status codes, model name, user ID) goes to logs. The user gets "We're experiencing high demand right now."</p>
<p><strong>Never let a provider error reach your UI.</strong> They leak implementation details, sometimes leak request contents, and mean nothing to the person reading them. Log the real thing and show a sentence.</p>
<h3 id="heading-handle-safety-blocks-as-content-not-errors">Handle Safety Blocks as Content, Not Errors</h3>
<p>A response blocked by the safety filter isn't an exception. It's a stream that stops. Handle it explicitly:</p>
<pre><code class="language-js">if (candidate.finishReason === 'SAFETY') {
  const safetyMsg = "\n\n[I can't continue this particular line of conversation. Let's pick it up somewhere else.]";
  fullResponse += safetyMsg;
  res.write(`data: ${JSON.stringify({ type: 'content', delta: safetyMsg })}\n\n`);
  break;
}
</code></pre>
<p>The message is appended to the stream, so the user sees the partial response plus an explanation rather than text that halts mid-sentence.</p>
<p>Individual chunks are also guarded, because one bad chunk shouldn't kill a good stream:</p>
<pre><code class="language-js">try {
  const chunkText = chunk.text();
  if (chunkText) { /* ... */ }
} catch (textErr) {
  console.warn(`[AI_LOG] Could not extract text from chunk: ${textErr.message}`);
}
</code></pre>
<h3 id="heading-dont-call-the-model-when-you-shouldnt">Don't Call the Model When You Shouldn't</h3>
<p>The most important failure handling doesn't involve the model at all. Incoming messages are checked against a crisis pattern <em>before</em> any API call:</p>
<pre><code class="language-js">if (CRISIS_RE.test(userMessage)) {
  return res.json({ success: true, replyText: CRISIS_REPLY });
}
</code></pre>
<p>The personality prompt has a crisis override, and it works. But "usually works" is not the standard for someone in danger. A regex and a fixed response are deterministic, instant, and can't be talked out of it by an unusual phrasing. <strong>When a failure mode is genuinely harmful, put a deterministic check in front of the model rather than trusting a prompt.</strong></p>
<h3 id="heading-what-the-user-actually-sees">What the User Actually Sees</h3>
<p>The text-to-speech feature distinguishes failure types, because they call for different responses:</p>
<pre><code class="language-jsx">{quotaReached &amp;&amp; !isSynthesizing &amp;&amp; (
  &lt;div className="text-center px-4"&gt;
    &lt;p className="text-xs text-amber-400 mb-1"&gt;Daily voice limit reached. Try again tomorrow.&lt;/p&gt;
    &lt;p className="text-xs text-slate-400"&gt;You can still record your own voice instead.&lt;/p&gt;
  &lt;/div&gt;
)}

{synthError &amp;&amp; !quotaReached &amp;&amp; !isSynthesizing &amp;&amp; (
  &lt;div className="text-center px-2"&gt;
    &lt;p className="text-xs text-red-400 mb-1"&gt;Could not prepare audio.&lt;/p&gt;
    &lt;button onClick={() =&gt; { /* clear caches, retry */ }}&gt;
      Try again
    &lt;/button&gt;
  &lt;/div&gt;
)}
</code></pre>
<p>Quota and transient error are different situations. Quota is amber, explains the limit, and <strong>offers the alternative path</strong> (record your own voice, which needs no API at all). Transient error is red, brief, and offers a retry that clears cached state first.</p>
<p>The general shape: <strong>every AI failure state should tell the user what happened in plain language and give them a next action.</strong> Retry, alternative path, or "come back tomorrow." A dead end with a red message is a bug even when the error handling technically worked.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<h3 id="heading-save-bad-outputs-from-day-one">Save Bad Outputs From Day One</h3>
<p>My best prompt material is verbatim failures. The invented-regret example ended up quoted in both the prompt and the QA checklist. I recovered those from memory and screenshots.</p>
<p>A <code>bad-outputs.md</code> appended to the moment something goes wrong would have made every iteration faster. This is the cheapest habit on this list and the one I'd adopt first.</p>
<h3 id="heading-write-the-qa-checklist-before-the-prompt-not-after">Write the QA Checklist Before the Prompt, Not After</h3>
<p>It came into existence after a regression shipped. Writing the cases first would have forced me to define good behavior concretely before trying to elicit it. And half of prompt engineering is <em>deciding what you actually want</em> in enough detail to check for it. The checklist is really a spec that happens to be executable by a human.</p>
<h3 id="heading-separate-concerns-in-the-prompt-earlier">Separate Concerns in the Prompt Earlier</h3>
<p>The length/personality collision was a self-inflicted wound from a prompt where voice, length, and reasoning were tangled in the same prose. Deciding upfront that each component owns exactly one dimension would have prevented an entire bug family.</p>
<h3 id="heading-log-more-than-ttft">Log More Than TTFT</h3>
<p>I have latency metrics and no quality metrics. I know how fast responses arrive, but I don't know how often they're good. Even something crude like a thumbs up/down, or logging responses that trip my red-flag list would turn quality from a thing I spot-check into a thing I can see trending.</p>
<p>Right now a slow degradation across a model version change would be invisible to me until a user mentioned it.</p>
<h3 id="heading-resist-making-prompts-longer-as-the-default-fix">Resist Making Prompts Longer as the Default Fix</h3>
<p>The Direct prompt is long — arguably too long. Every bug produced another rule, and rules accumulate. Some are surely now redundant or in tension.</p>
<p>Adding text is the easiest response to a bad output and it's often the wrong one. The better move is frequently to fix an <em>example</em> rather than add a rule, since examples carry more signal per token. I don't have a good pruning process, and I'd build one earlier.</p>
<h3 id="heading-what-id-keep">What I'd Keep</h3>
<p>There are definitely some good things I'd keep, like the config file with fallback models, the server-side-only key boundary, defensive parsing with degradation, and the deterministic crisis check in front of the model. None of those have caused a problem since.</p>
<h2 id="heading-takeaways">Takeaways</h2>
<p>Here are five rules worth stealing:</p>
<h3 id="heading-1-dont-use-ai-unless-the-input-is-unbounded-and-the-output-requires-judgment">1. Don't use AI unless the input is unbounded <em>and</em> the output requires judgment.</h3>
<p>Make sure both conditions are true. Otherwise write the code. It'll be faster, cheaper, and more debuggable. A regex that's right most of the time beats a model call that's right slightly more often at the cost of latency, spend, and four new failure modes.</p>
<h3 id="heading-2-generic-output-usually-means-the-model-is-answering-the-average-version-of-your-input">2. Generic output usually means the model is answering the average version of your input.</h3>
<p>Before fixing tone, check whether the model invented context the user never supplied. Fix the projection and the tone often follows. Fix the tone alone and you get confident-sounding fabrication.</p>
<h3 id="heading-3-ban-specific-phrases-then-ban-the-overcorrection">3. Ban specific phrases, then ban the overcorrection.</h3>
<p>"Be direct" means nothing. A list of forbidden openings means something exact. And when you forbid one thing, forbid its opposite in the same breath, as models reliably overshoot from validation into contempt.</p>
<h3 id="heading-4-use-rules-for-boundaries-and-examples-for-voice-as-they-fail-differently">4. Use rules for boundaries and examples for voice, as they fail differently.</h3>
<p>Rules-only gives you correct-but-lifeless. Examples-only gives you on-voice-but-unpredictable at the edges. Include the same input twice with different valid outputs, or the model will parrot your single example.</p>
<h3 id="heading-5-write-a-manual-qa-checklist-with-expected-ranges-not-expected-outputs">5. Write a manual QA checklist with "expected ranges," not expected outputs.</h3>
<p>You can't assert on non-deterministic text, but you can specify a region and enumerate failures. Make the criteria mostly negative, derive every case from a real regression, and include a symptom-to-cause map for the version of you that comes back in six months.</p>
<p>And one that underpins all of them: <strong>your feature's quality lives in the failures you can name.</strong> Every technique here — the banned lists, the counterexamples, the QA cases, the red flags — is a bad output someone bothered to write down precisely. The prompt is just where those notes ended up.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
