<?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>Mon, 03 Aug 2026 22:40:38 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Why Your Quantum Circuit Works in a Simulator but Fails on Real Hardware ]]>
                </title>
                <description>
                    <![CDATA[ If the exact same quantum circuit works perfectly in a simulator, why does it often produce different results on a real quantum computer? That question catches almost every quantum developer by surpri ]]>
                </description>
                <link>https://www.freecodecamp.org/news/why-your-quantum-circuit-works-in-a-simulator-but-fails-on-real-hardware/</link>
                <guid isPermaLink="false">6a711081f297e5e86c13916d</guid>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ quantum computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ hardware ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Casmir Onyekani ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 22:04:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/8e79825e-752f-4667-88fd-548e3687455d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If the exact same quantum circuit works perfectly in a simulator, why does it often produce different results on a real quantum computer?</p>
<p>That question catches almost every quantum developer by surprise. Understanding it is essential if you plan to build larger, more reliable quantum applications.</p>
<p>This tutorial assumes you're already comfortable creating and executing basic quantum circuits in <a href="https://www.ibm.com/quantum/qiskit">Qiskit</a>.</p>
<p>The first time you execute a circuit on real hardware, you'd expect the output to match the simulator. After all, the code, algorithm, and compiler remain the same. Yet the results often do.</p>
<p>Sometimes the difference is barely noticeable. Other times, a circuit that looked perfect in simulation suddenly produces outputs that are difficult to explain. As your circuits become deeper, involve more qubits, or include more gates, those differences become increasingly significant.</p>
<p>When I first encountered this behavior, my instinct was the same as many beginners: <em>I must have made a mistake somewhere.</em></p>
<p>I reviewed my code, checked my gates, and compared the circuit diagrams. I reran the simulator. Everything looked correct. The problem wasn't the algorithm. It was the hardware.</p>
<p>Unlike the ideal environment simulated by Qiskit Aer, real quantum processors operate in a world filled with imperfections. Qubits gradually lose their quantum information. Gates are never perfectly accurate. Measurements introduce uncertainty. Even qubits waiting for their turn in a computation continue interacting with their environment, accumulating errors before they perform another operation.</p>
<p>These challenges are collectively known as <strong>quantum noise</strong>, and they are one of the biggest obstacles preventing today's quantum computers from performing long, complex calculations reliably.</p>
<p>Fortunately, quantum researchers haven't been standing still. Over the years, they've developed a growing collection of techniques to reduce the impact of noise and improve the quality of quantum computations. Broadly speaking, these techniques fall into two categories:</p>
<ul>
<li><p><strong>Error mitigation</strong>, which estimates and compensates for errors after a circuit has executed.</p>
</li>
<li><p><strong>Error suppression</strong>, which attempts to prevent many of those errors from occurring in the first place while the circuit is running.</p>
</li>
</ul>
<p>More recently, these advanced techniques have started becoming accessible through developer-friendly tools instead of requiring researchers to manually tune every circuit.</p>
<p>One of the newest examples is <strong>Orbit</strong>, an automated quantum error suppression solution available through the Qiskit Functions Catalog. Rather than requiring developers to become specialists in techniques like dynamical decoupling, Orbit is designed to integrate advanced error suppression into existing Qiskit workflows with minimal additional effort.</p>
<p>But before we can appreciate why tools like Orbit matter, we first need to understand the problem they're solving.</p>
<p>That's exactly what we'll do in this tutorial. Instead of jumping straight into a new tool, we'll investigate one of the most common and most important questions in quantum computing:</p>
<p><strong>Why do quantum circuits behave differently on real hardware than they do in a simulator?</strong></p>
<p>Along the way, you'll learn where quantum errors come from, how to reproduce many of them locally using Qiskit Aer, why larger circuits become increasingly difficult to execute reliably, and how modern error suppression techniques help developers get more useful results from today's quantum computers.</p>
<p>By the end of this guide, you'll understand not only <em>what</em> causes quantum circuits to fail on real hardware, but also <em>what developers can do about it</em>.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-the-experiment-running-the-same-circuit-in-a-simulator-and-on-real-hardware">The Experiment: Running the Same Circuit in a Simulator and on Real Hardware</a></p>
<ul>
<li><p><a href="#heading-starting-with-a-familiar-circuit">Starting with a Familiar Circuit</a></p>
</li>
<li><p><a href="#heading-step-1-running-the-circuit-on-the-simulator">Step 1: Running the Circuit on the Simulator</a></p>
</li>
<li><p><a href="#heading-step-2-running-the-same-circuit-on-a-real-quantum-computer">Step 2: Running the Same Circuit on a Real Quantum Computer</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-happens-inside-a-real-quantum-computer">What Happens Inside a Real Quantum Computer?</a></p>
<ul>
<li><p><a href="#heading-from-python-code-to-physical-qubits">From Python Code to Physical Qubits</a></p>
</li>
<li><p><a href="#heading-every-quantum-operation-is-a-physical-process">Every Quantum Operation Is a Physical Process</a></p>
</li>
<li><p><a href="#heading-what-is-quantum-noise">What Is Quantum Noise?</a></p>
</li>
<li><p><a href="#heading-four-common-sources-of-quantum-noise">Four Common Sources of Quantum Noise</a></p>
</li>
<li><p><a href="#heading-why-simulators-dont-show-these-problems">Why Simulators Don't Show These Problems</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-simulating-quantum-noise-with-qiskit-aer">Simulating Quantum Noise with Qiskit Aer</a></p>
<ul>
<li><p><a href="#heading-creating-a-simple-noise-model">Creating a Simple Noise Model</a></p>
</li>
<li><p><a href="#heading-running-the-bell-state-with-noise">Running the Bell State with Noise</a></p>
</li>
<li><p><a href="#heading-comparing-the-results">Comparing the Results</a></p>
</li>
<li><p><a href="#heading-making-the-noise-worse">Making the Noise Worse</a></p>
</li>
<li><p><a href="#heading-why-not-just-remove-the-noise">Why Not Just Remove the Noise?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-error-mitigation-vs-error-suppression-whats-the-difference">Error Mitigation vs. Error Suppression: What's the Difference?</a></p>
<ul>
<li><p><a href="#heading-what-is-error-mitigation">What Is Error Mitigation?</a></p>
</li>
<li><p><a href="#heading-what-is-error-suppression">What Is Error Suppression?</a></p>
</li>
<li><p><a href="#heading-comparing-the-two-approaches">Comparing the Two Approaches</a></p>
</li>
<li><p><a href="#heading-why-error-suppression-is-becoming-more-important">Why Error Suppression Is Becoming More Important</a></p>
</li>
<li><p><a href="#heading-introducing-dynamical-decoupling">Introducing Dynamical Decoupling</a></p>
</li>
<li><p><a href="#heading-where-orbit-fits">Where Orbit Fits</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-automated-error-suppression-fits-into-a-modern-quantum-workflow">How Automated Error Suppression Fits into a Modern Quantum Workflow</a></p>
<ul>
<li><p><a href="#heading-moving-from-manual-optimization-to-automated-workflows">Moving from Manual Optimization to Automated Workflows</a></p>
</li>
<li><p><a href="#heading-what-orbit-publicly-says-it-does">What Orbit Publicly Says It Does</a></p>
</li>
<li><p><a href="#heading-a-real-hardware-example">A Real Hardware Example</a></p>
</li>
<li><p><a href="#heading-should-you-use-orbit">Should You Use Orbit?</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-the-experiment-running-the-same-circuit-in-a-simulator-and-on-real-hardware">The Experiment: Running the Same Circuit in a Simulator and on Real Hardware</h2>
<p>One of the biggest advantages of learning quantum computing with Qiskit is that you don't need immediate access to a quantum computer. You can write, test, and debug your circuits locally using Qiskit Aer before running them on real IBM Quantum hardware.</p>
<p>Let's begin with one of the first circuits you may likely build as a quantum developer: <strong>the Bell State</strong>.</p>
<h3 id="heading-starting-with-a-familiar-circuit">Starting with a Familiar Circuit</h3>
<p>The Bell State is often the first example developers encounter when learning quantum programming because it demonstrates one of quantum computing's most fascinating properties: <a href="https://quantum.microsoft.com/en-us/insights/education/concepts/entanglement"><strong>entanglement</strong></a>.</p>
<p>Create <code>bell_state.py</code> file:</p>
<pre><code class="language-python">from qiskit import QuantumCircuit

# Create a quantum circuit with two qubits and two classical bits 
qc = QuantumCircuit(2, 2)

# Place the first qubit into superposition 
qc.h(0)

# Entangle the second qubit with the first 
qc.cx(0, 1) 

# Measure both qubits 
qc.measure([0, 1], [0, 1]) 

print(qc)
</code></pre>
<p>In this code, the Hadamard gate places the first qubit into a superposition, while the CNOT gate entangles the second qubit with it. Once measured, both qubits should always produce matching values.</p>
<p>In an ideal quantum computer, you should expect only two measurement outcomes:</p>
<ul>
<li><p><code>00</code></p>
</li>
<li><p><code>11</code></p>
</li>
</ul>
<p>Each outcome should appear with roughly the same probability.</p>
<p>States like <code>01</code> and <code>10</code> shouldn't appear at all because they violate the expected Bell State correlations.</p>
<h3 id="heading-step-1-running-the-circuit-on-the-simulator">Step 1: Running the Circuit on the Simulator</h3>
<p>You will begin by executing the circuit using the Qiskit Aer simulator:</p>
<pre><code class="language-python">from qiskit_aer import AerSimulator

simulator = AerSimulator()

result = simulator.run(
    qc,
    shots=4096
).result()

counts = result.get_counts()

print(counts)
</code></pre>
<p>Adding your simulator to <code>bell_state.py</code>, you now have:</p>
<pre><code class="language-python">from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator

qc = QuantumCircuit(2, 2)

qc.h(0)

qc.cx(0, 1)

qc.measure([0, 1], [0, 1])

simulator = AerSimulator()

result = simulator.run(
    qc,
    shots=4096
).result()

counts = result.get_counts()

print(counts)
</code></pre>
<p>Make sure your virtual environment is activated (<code>source .venv/bin/activate</code>), and you installed Qiskit and Qiskit Aer (<code>pip install qiskit qiskit-aer</code>).</p>
<p>Run: <code>python bell_state.py</code>, a typical output looks like this:</p>
<pre><code class="language-plaintext">{'00': 2039, '11': 2057}
</code></pre>
<p>Your numbers will likely be slightly different because quantum measurements are probabilistic. However, the overall pattern should remain the same.</p>
<p>Only <code>00</code> and <code>11</code> appear. There are no unexpected measurement outcomes, and everything behaves exactly as quantum theory predicts.</p>
<p>At this point, it's easy to feel confident that your circuit is correct. And it is. But there's an important detail hiding behind these perfect results.</p>
<blockquote>
<p>Note: The simulator assumes an ideal quantum computer.</p>
</blockquote>
<p>It doesn't have to worry about hardware limitations because it's simply calculating the mathematical evolution of your quantum state.</p>
<p>Among other things, the simulator assumes that:</p>
<ul>
<li><p>Every quantum gate is executed perfectly.</p>
</li>
<li><p>Qubits never lose their quantum state.</p>
</li>
<li><p>Measurements are always accurate.</p>
</li>
<li><p>The environment never interferes with the computation.</p>
</li>
<li><p>No additional noise is introduced while the circuit runs.</p>
</li>
</ul>
<p>Those assumptions make simulators incredibly valuable for learning, debugging, and verifying quantum algorithms.</p>
<p>Unfortunately, real quantum processors don't operate under ideal conditions.</p>
<h3 id="heading-step-2-running-the-same-circuit-on-a-real-quantum-computer">Step 2: Running the Same Circuit on a Real Quantum Computer</h3>
<p>Now imagine taking this exact same circuit and executing it on a real quantum processor.</p>
<p>Notice that nothing changes. Not the code, algorithm, or the Bell State itself. The only thing we're changing is <strong>where the circuit runs</strong>.</p>
<p>If you submit this circuit to a real quantum computer, you might expect results that closely match the simulator. After all, if the algorithm is correct, shouldn't the output be the same?</p>
<p>In reality, you'll often observe something more like this:</p>
<pre><code class="language-plaintext">{
    '00': 1912,
    '11': 1834,
    '01': 161,
    '10': 189
}
</code></pre>
<p>The first thing that stands out is the appearance of two unexpected outcomes: <code>01</code> and <code>10</code>.</p>
<p>Those states weren't present in the simulator. So where did they come from? The answer isn't that your code suddenly became incorrect.</p>
<p>The Bell State circuit hasn't changed. The simulator wasn't misleading you.</p>
<p>Instead, the quantum hardware is introducing small imperfections while your circuit executes.</p>
<p>A gate may be applied with slightly less than perfect accuracy. A qubit may begin losing its quantum information before the computation finishes. A measurement may occasionally report the wrong value.</p>
<p>Individually, these errors are usually very small. Collectively, they begin to change the final measurement statistics. For a simple Bell State, the differences are relatively minor.</p>
<p>But quantum algorithms rarely stop at two qubits and two gates.</p>
<p>As circuits become deeper and more complex, these small imperfections accumulate. Eventually, they can overwhelm the quantum information your algorithm is trying to preserve, making the final results less reliable.</p>
<p>This is one of the biggest challenges facing today's quantum computers.</p>
<p>A simulator shows us <strong>how a quantum algorithm is expected to behave</strong> under ideal conditions.</p>
<p>Real hardware shows us <strong>how that same algorithm behaves in the presence of noise</strong>. Closing that gap is one of the central goals of modern quantum computing research.</p>
<p>Before you explore techniques like <strong>quantum error suppression</strong> or see how tools like <strong>Orbit</strong> help automate parts of that process, you first need to understand where these errors come from.</p>
<h2 id="heading-what-happens-inside-a-real-quantum-computer">What Happens Inside a Real Quantum Computer?</h2>
<p>At this point, we've established something that surprises almost every new quantum developer:</p>
<p>The same quantum circuit can produce different results depending on where it runs.</p>
<p>But that naturally leads to another question:</p>
<blockquote>
<p><strong>What exactly is happening inside a real quantum computer that doesn't happen inside a simulator?</strong></p>
</blockquote>
<p>To answer that, you need to look beyond your Python code and understand what happens after you click <strong>Run</strong>.</p>
<h3 id="heading-from-python-code-to-physical-qubits">From Python Code to Physical Qubits</h3>
<p>When you execute a circuit with Qiskit Aer, the simulator performs mathematical calculations to determine how the quantum state evolves. It works with complex numbers and linear algebra, faithfully applying each gate exactly as quantum mechanics predicts.</p>
<p>Nothing interferes with the computation unless you explicitly introduce a noise model.</p>
<p>Real quantum computers work very differently. Instead of manipulating mathematical objects, they manipulate <strong>physical qubits</strong>.</p>
<p>Depending on the hardware architecture, these qubits might be:</p>
<ul>
<li><p>superconducting circuits cooled to temperatures colder than outer space</p>
</li>
<li><p>trapped ions suspended by electromagnetic fields</p>
</li>
<li><p>neutral atoms held in optical tweezers</p>
</li>
<li><p>another emerging quantum technology.</p>
</li>
</ul>
<p>Although these platforms use different hardware, they all share one important characteristic:</p>
<p><strong>Qubits are extremely fragile.</strong></p>
<p>Unlike classical bits, which remain either <code>0</code> or <code>1</code> until they're changed, qubits must preserve delicate quantum properties such as superposition and entanglement throughout an entire computation.</p>
<p>Maintaining those properties is far more difficult than it sounds.</p>
<h3 id="heading-every-quantum-operation-is-a-physical-process">Every Quantum Operation Is a Physical Process</h3>
<p>When you write code like this:</p>
<pre><code class="language-python">qc.h(0)
qc.cx(0, 1)
</code></pre>
<p>It looks almost effortless. Two lines of Python, less than a second to execute.</p>
<p>Behind the scenes, however, the quantum processor performs a carefully orchestrated series of physical operations.</p>
<p>Control electronics generate microwave pulses or laser pulses. Those signals travel through specialized hardware.</p>
<p>The pulses interact with individual qubits for incredibly short periods of time. The timing must be extraordinarily precise.</p>
<p>If any part of this process deviates even slightly from what was intended, the resulting quantum state can change.</p>
<p>Now imagine repeating this process dozens, hundreds, or even thousands of times within a single algorithm. Tiny imperfections begin to accumulate.</p>
<p>Eventually, those small errors become noticeable in the final measurement results. This is what we broadly refer to as <strong>quantum noise</strong>.</p>
<h3 id="heading-what-is-quantum-noise">What Is Quantum Noise?</h3>
<p>This is a general term for anything that causes a quantum computer to drift away from the ideal behavior predicted by quantum mechanics.</p>
<p>It doesn't usually mean something dramatic has happened.</p>
<p>Most of the time, the errors are incredibly small.</p>
<p>A gate may rotate a qubit by an angle that's only slightly different from the intended value.</p>
<p>A qubit may lose a little of its quantum information while waiting for another operation. A measurement might occasionally report the wrong state.</p>
<p>Each error seems insignificant on its own. The challenge is that quantum algorithms often involve many operations.</p>
<p>Even tiny inaccuracies begin to add up. Imagine trying to copy a handwritten page. One typo probably doesn't matter.</p>
<p>Copy the same page hundreds of times, introducing one small typo during each copy, and eventually the final document barely resembles the original.</p>
<p>Quantum circuits behave in much the same way. The longer the computation continues, the more opportunities there are for errors to accumulate.</p>
<h3 id="heading-four-common-sources-of-quantum-noise">Four Common Sources of Quantum Noise</h3>
<p>Although researchers study many different types of quantum errors, most developers encounter four major categories.</p>
<p>Understanding these will help you make sense of why quantum hardware behaves differently from an ideal simulator.</p>
<p><strong>1. Decoherence</strong></p>
<p>One of the biggest challenges in quantum computing is <strong>decoherence</strong>. A qubit can maintain its quantum state only for a limited amount of time. Eventually, interactions with its surrounding environment cause it to lose the information stored in its superposition.</p>
<p>Think of spinning a coin on a table. When you first spin it, the coin exists in a rapidly changing state that's neither clearly heads nor tails. As time passes, friction slows it down until it finally settles.</p>
<p>Qubits experience a similar loss of information. Except instead of friction, they're affected by tiny interactions with the surrounding environment.</p>
<p>If your circuit takes too long to execute, some qubits may begin losing their quantum information before the computation finishes.</p>
<p><strong>2. Gate Errors</strong></p>
<p>Every quantum gate is a physical operation. Ideally, a Hadamard gate always performs exactly the same transformation. In reality, no hardware is perfect.</p>
<p>The pulse implementing the gate may be slightly stronger, weaker, or slightly delayed than intended. These tiny inaccuracies create <strong>gate errors</strong>.</p>
<p>One imperfect gate isn't usually a problem, hundreds of imperfect gates quickly become one</p>
<p>This is one reason deeper quantum circuits tend to perform worse than shallow ones.</p>
<p><strong>3. Measurement Errors</strong></p>
<p>Even if your computation completes successfully, there's still one final challenge:</p>
<p>Reading the result.</p>
<p>Measuring a qubit is itself a physical process. Sometimes the hardware incorrectly identifies a qubit as <code>1</code> when it should be <code>0</code>, or vice versa.</p>
<p>Imagine stepping on a bathroom scale that occasionally reports your weight two kilograms heavier than it actually is.</p>
<p>The measurement instrument — not you — is introducing the error.</p>
<p>Quantum computers face a similar problem when reading qubit states.</p>
<p><strong>4. Idle Errors</strong></p>
<p>One of the least intuitive sources of quantum noise occurs when a qubit isn't doing anything at all.</p>
<p>Suppose one qubit is waiting while another qubit is being measured or participating in a multi-qubit operation.</p>
<p>Although it appears idle, it doesn't freeze in time. The qubit continues interacting with its environment. During that waiting period, it can gradually lose coherence.</p>
<p>As quantum circuits become larger, these idle periods become more common.</p>
<p>Reducing the impact of these waiting times is one of the motivations behind advanced <strong>error suppression</strong> techniques such as <strong>dynamical decoupling</strong> — a technique we'll explore later when we discuss Orbit.</p>
<h3 id="heading-why-simulators-dont-show-these-problems">Why Simulators Don't Show These Problems</h3>
<p>If you've only worked with Qiskit Aer so far, you may wonder why you've never encountered any of these issues.</p>
<p>The answer is simple.</p>
<p>By default, the simulator isn't trying to model an imperfect quantum computer. It's trying to model <strong>an ideal one</strong>.</p>
<p>That makes it an excellent learning environment because you can verify whether your algorithm is logically correct without worrying about hardware limitations.</p>
<p>But it also means a simulator can't fully prepare you for what happens on real quantum devices.</p>
<p>To understand that difference, you need to recreate it yourself.</p>
<p>Fortunately, Qiskit gives us a way to do exactly that.</p>
<p>Instead of waiting until you have access to a real quantum computer, you can intentionally introduce realistic noise into your local simulator and observe how your Bell State begins to change.</p>
<h2 id="heading-simulating-quantum-noise-with-qiskit-aer">Simulating Quantum Noise with Qiskit Aer</h2>
<p>So far, you've compared two different worlds.</p>
<p>In the first world, our Bell State circuit runs inside an ideal simulator, where every quantum operation is mathematically perfect.</p>
<p>In the second world, that same circuit runs on a real quantum processor, where qubits are constantly affected by noise from their surrounding environment.</p>
<p>The obvious challenge is this:</p>
<p><strong>What if you don't have access to a quantum computer?</strong></p>
<p>Can you still learn how noise affects your algorithms? Fortunately, you can.</p>
<p>One of Qiskit's most useful features is its ability to simulate realistic hardware imperfections locally using <strong>Qiskit Aer</strong>. Instead of waiting until your circuit reaches a real quantum processor, you can inject different kinds of noise into your simulator and observe how those imperfections influence the final results.</p>
<p>This allows you to experiment, debug, and better understand the behavior of quantum algorithms — all from your own computer.</p>
<p>Let's see how it works.</p>
<h3 id="heading-creating-a-simple-noise-model">Creating a Simple Noise Model</h3>
<p>Qiskit Aer includes a collection of tools for building custom noise models. These models let you simulate many of the errors you've just learned about, including gate errors, measurement errors, and qubit decoherence.</p>
<p>For your first experiment, keep things simple by introducing a small amount of random error after every single-qubit and two-qubit gate:</p>
<pre><code class="language-python">from qiskit_aer.noise import NoiseModel, depolarizing_error

# Create an empty noise model
noise_model = NoiseModel()

# Define gate errors
single_qubit_error = depolarizing_error(0.01, 1)
two_qubit_error = depolarizing_error(0.03, 2)

# Apply errors to common quantum gates
noise_model.add_all_qubit_quantum_error(
    single_qubit_error,
    ["h", "x", "y", "z"]
)

noise_model.add_all_qubit_quantum_error(
    two_qubit_error,
    ["cx"]
)
</code></pre>
<p>In this code you created an empty <code>NoiseModel</code> and defined two <strong>depolarizing errors</strong>.</p>
<p>A depolarizing error is one of the most common ways to simulate hardware noise. Instead of applying a gate perfectly every time, the simulator introduces a small probability that the qubit's state becomes partially randomized.</p>
<p>Think of it like taking a slightly blurry photograph.</p>
<p>The picture still resembles the original, but every small imperfection makes it a little harder to recover the exact details.</p>
<p>That's essentially what depolarizing noise does to a quantum state.</p>
<p>Notice that we're using two different error probabilities:</p>
<ul>
<li><p><strong>1%</strong> for single-qubit gates</p>
</li>
<li><p><strong>3%</strong> for two-qubit gates</p>
</li>
</ul>
<p>This reflects an important reality of today's quantum hardware.</p>
<p>Two-qubit operations are generally more difficult to perform accurately than single-qubit operations, which is why they often have lower fidelities on real quantum processors.</p>
<h3 id="heading-running-the-bell-state-with-noise">Running the Bell State with Noise</h3>
<p>Rename the <code>bell_state.py</code> we used earlier to <code>bell_state_noise.py</code> to specify adding a <code>NoiseModel</code>.</p>
<p>Reconfigure the simulator with our noise model:</p>
<pre><code class="language-python">from qiskit_aer import AerSimulator

noisy_simulator = AerSimulator(
    noise_model=noise_model
)

compiled = transpile(qc, noisy_simulator)

job = noisy_simulator.run(
    compiled,
    shots=4096
)

result = job.result()

counts = result.get_counts()

print(counts)
</code></pre>
<p>At this point your <code>bell_state_noise.py</code> should look like this:</p>
<pre><code class="language-python">from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel, depolarizing_error

# Step 1: Build the Bell State circuit
qc = QuantumCircuit(2, 2)

# Put qubit 0 into superposition
qc.h(0)

# Entangle qubit 1 with qubit 0
qc.cx(0, 1)

# Measure both qubits
qc.measure([0, 1], [0, 1])

print("Bell State Circuit")
print(qc)


# Step 2: Run on the ideal simulator

ideal_simulator = AerSimulator()

ideal_result = ideal_simulator.run(
    qc,
    shots=4096
).result()

ideal_counts = ideal_result.get_counts()

print("\nIdeal Simulator Results")
print(ideal_counts)


# Step 3: Create a noise model
noise_model = NoiseModel()

single_qubit_error = depolarizing_error(0.01, 1)
two_qubit_error = depolarizing_error(0.03, 2)

noise_model.add_all_qubit_quantum_error(
    single_qubit_error,
    ["h", "x", "y", "z"]
)

noise_model.add_all_qubit_quantum_error(
    two_qubit_error,
    ["cx"]
)

# Step 4: Run with simulated noise
noisy_simulator = AerSimulator(
    noise_model=noise_model
)

noisy_result = noisy_simulator.run(
    qc,
    shots=4096
).result()

noisy_counts = noisy_result.get_counts()

print("\nNoisy Simulator Results")
print(noisy_counts)
</code></pre>
<p>For windows, to run:</p>
<p>Activate your virtual environment <code>source .venv/Scripts/activate</code> then run <code>python bell_state_noise.py</code></p>
<p>You may see output similar to this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/647d7b660f441a49aa878a9e/99956b1a-edbd-4568-bd43-d7bc77c9071b.png" alt="terminal output" style="display:block;margin:0 auto" width="1019" height="412" loading="lazy">

<p>Your exact numbers will be different, but one thing should immediately stand out.</p>
<p>Unlike the ideal simulator, two unexpected states have appeared:</p>
<ul>
<li><p><code>01</code></p>
</li>
<li><p><code>10</code></p>
</li>
</ul>
<p>These outcomes shouldn't exist in a perfect Bell State.</p>
<p>Yet they now appear because we intentionally introduced hardware imperfections into the simulation.</p>
<p>Without changing a single line of our quantum algorithm, the results became noticeably less reliable.</p>
<h3 id="heading-comparing-the-results">Comparing the Results</h3>
<p>Let's compare all three scenarios we've discussed so far.</p>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Typical Results</th>
</tr>
</thead>
<tbody><tr>
<td>Ideal simulator</td>
<td>Only <code>00</code> and <code>11</code></td>
</tr>
<tr>
<td>Noisy simulator</td>
<td>Mostly <code>00</code> and <code>11</code>, with a few <code>01</code> and <code>10</code></td>
</tr>
<tr>
<td>Real hardware</td>
<td>Similar behavior, but influenced by the actual device's physical characteristics</td>
</tr>
</tbody></table>
<p>The noisy simulator isn't trying to perfectly reproduce a specific IBM Quantum processor. Instead, it helps you understand <strong>how quantum noise changes the behavior of an algorithm</strong>.</p>
<p>That's an important distinction. You're no longer asking whether your Bell State circuit is correct. You already know it is.</p>
<p>Instead, you're asking a new question:</p>
<blockquote>
<p><strong>How resilient is my circuit when the hardware isn't perfect?</strong></p>
</blockquote>
<p>That's the kind of question quantum developers ask every day.</p>
<h3 id="heading-making-the-noise-worse">Making the Noise Worse</h3>
<p>To see how quickly errors accumulate, try increasing the depolarizing probabilities.</p>
<p>For example, change the code to:</p>
<pre><code class="language-python">single_qubit_error = depolarizing_error(0.05, 1)
two_qubit_error = depolarizing_error(0.10, 2)
</code></pre>
<p>Run the circuit again.</p>
<p>You'll likely notice that the incorrect outcomes become much more common.</p>
<p>The Bell State begins to lose its characteristic correlation, and the measurement distribution drifts farther away from the ideal 50/50 split.</p>
<p>This simple experiment illustrates an important principle of quantum computing.</p>
<p>Small increases in hardware noise can have a surprisingly large impact on the quality of your results.</p>
<p>Now imagine running a circuit containing hundreds of gates instead of just two.</p>
<p>Each additional operation introduces another opportunity for error.</p>
<p>By the time the computation finishes, the accumulated noise may overwhelm the useful quantum information your algorithm was trying to preserve.</p>
<p>This is why reducing noise has become one of the biggest priorities in quantum computing.</p>
<h3 id="heading-why-not-just-remove-the-noise">Why Not Just Remove the Noise?</h3>
<p>At this point, you might wonder:</p>
<blockquote>
<p><strong>If noise causes so many problems, why can't you simply eliminate it?</strong></p>
</blockquote>
<p>Researchers have been working toward that goal for decades.</p>
<p>The challenge is that quantum systems are extraordinarily sensitive.</p>
<p>Completely isolating qubits from their environment while simultaneously controlling and measuring them is one of the hardest engineering problems in modern science.</p>
<p>Instead of waiting for perfect hardware, researchers have developed techniques that help quantum computers produce more reliable results even when noise is unavoidable. These techniques fall into two categories as mentioned: <em><strong>Error mitigation* and *Error suppression</strong></em></p>
<p>Although both approaches aim to improve the quality of quantum computations, they solve the problem in fundamentally different ways.</p>
<p>Understanding that distinction is essential before we explore how Orbit brings automated error suppression into modern Qiskit workflows.</p>
<h2 id="heading-error-mitigation-vs-error-suppression-whats-the-difference">Error Mitigation vs. Error Suppression: What's the Difference?</h2>
<p>After seeing how even a small amount of noise can change the outcome of a simple Bell State circuit, it's natural to ask an important question:</p>
<blockquote>
<p><strong>If quantum hardware is so noisy, how do researchers still run useful quantum algorithms?</strong></p>
</blockquote>
<p>The answer is that they rarely rely on raw hardware results alone. Instead, they use <strong>error mitigation</strong> and <strong>error suppression</strong> to improve the quality of quantum computations.</p>
<p>Although these terms are sometimes used interchangeably, they solve two different problems.</p>
<p>Understanding the difference is essential because <strong>Orbit</strong> belongs to one of these categories — not the other.</p>
<p>Let's look at each approach.</p>
<h3 id="heading-what-is-error-mitigation">What Is Error Mitigation?</h3>
<p>Imagine taking a slightly blurry photograph. Once the picture has been taken, you open an editing application to sharpen the image, adjust the colors, and reduce the blur.</p>
<p>You didn't prevent the camera from capturing a blurry image. Instead, you improved the image <strong>after</strong> it was captured.</p>
<p>That's essentially what <strong>error mitigation</strong> does.</p>
<p>Error mitigation doesn't stop errors from occurring while the quantum circuit runs. Instead, it uses mathematical and statistical techniques to estimate how much noise affected the computation and then attempts to compensate for it after execution.</p>
<p>The goal isn't to create a perfect quantum computer. The goal is to extract a better approximation of the correct answer from imperfect hardware.</p>
<p>A simplified workflow looks like this:</p>
<pre><code class="language-text">Write Circuit
       ↓
Run on Noisy Hardware
       ↓
Collect Results
       ↓
Estimate Hardware Errors
       ↓
Correct the Final Output
</code></pre>
<p>This approach has become an important part of today's quantum computing landscape because it doesn't require fault-tolerant quantum hardware.</p>
<p>Instead, it works with the devices we have today.</p>
<p>Some common error mitigation techniques include:</p>
<ul>
<li><p>Measurement error mitigation</p>
</li>
<li><p>Zero-noise extrapolation (ZNE)</p>
</li>
<li><p>Probabilistic error cancellation (PEC)</p>
</li>
<li><p>Clifford data regression (CDR)</p>
</li>
</ul>
<p>You don't need to understand these techniques in detail right now.</p>
<p>The important takeaway is that error mitigation tries to improve the final answer after the computation has already finished.</p>
<h3 id="heading-what-is-error-suppression">What Is Error Suppression?</h3>
<p>Error suppression takes a very different approach.</p>
<p>Instead of correcting errors after the circuit finishes, it tries to <strong>prevent many of those errors from happening in the first place</strong>.</p>
<p>Imagine you're hiking through a muddy trail. Error mitigation is like cleaning your boots after the hike. Error suppression is like wearing waterproof boots before you start walking.</p>
<p>Both approaches improve the final outcome. One acts <strong>after</strong> the problem occurs. The other acts <strong>during</strong> the journey to reduce the problem altogether.</p>
<p>A simplified workflow looks like this:</p>
<pre><code class="language-text">Write Circuit
      ↓
Reduce Noise During Execution
      ↓
Execute Circuit
      ↓
Measure Results
</code></pre>
<p>Instead of estimating corrections afterward, error suppression focuses on protecting fragile quantum information while the computation is taking place.</p>
<p>This often involves techniques that reduce the impact of environmental noise, improve gate execution, or protect qubits during idle periods.</p>
<p>One of the best-known examples is dynamical decoupling, a technique you'll explore shortly</p>
<h3 id="heading-comparing-the-two-approaches">Comparing the Two Approaches</h3>
<p>Although both methods improve quantum computations, they operate at different stages of the workflow.</p>
<table>
<thead>
<tr>
<th>Error Mitigation</th>
<th>Error Suppression</th>
</tr>
</thead>
<tbody><tr>
<td>Applied after circuit execution</td>
<td>Applied while the circuit executes</td>
</tr>
<tr>
<td>Estimates and compensates for errors</td>
<td>Attempts to reduce errors before they accumulate</td>
</tr>
<tr>
<td>Focuses on improving measured results</td>
<td>Focuses on protecting the quantum state itself</td>
</tr>
<tr>
<td>Often relies on classical post-processing</td>
<td>Often modifies or augments the quantum circuit</td>
</tr>
</tbody></table>
<p>Neither approach completely eliminates quantum noise.</p>
<p>Instead, they complement each other.</p>
<p>In fact, you'll often get better results by combining both techniques</p>
<h3 id="heading-why-error-suppression-is-becoming-more-important">Why Error Suppression Is Becoming More Important</h3>
<p>As quantum algorithms become larger, the number of opportunities for noise to accumulate also increases.</p>
<p>Imagine a circuit containing only two gates, a tiny error may have almost no noticeable effect.</p>
<p>Now imagine a circuit containing hundreds or thousands of gates. Those same tiny errors can accumulate until the final result becomes unreliable.</p>
<p>This is especially challenging for algorithms that require qubits to remain coherent over longer periods or spend time waiting while other operations complete.</p>
<p>In these situations, reducing noise during execution becomes increasingly valuable.</p>
<p>Rather than trying to recover lost information afterward, researchers look for ways to preserve that information before it disappears.</p>
<p>That's where error suppression techniques have attracted significant attention.</p>
<h3 id="heading-introducing-dynamical-decoupling">Introducing Dynamical Decoupling</h3>
<p>This is one of the most widely studied error suppression techniques. The name sounds intimidating, but the underlying idea is surprisingly intuitive.</p>
<p>Imagine balancing a broomstick upright on your hand. If you leave your hand perfectly still, the broomstick quickly falls over. But if you make small, carefully timed adjustments, you can keep it balanced much longer.</p>
<p>You're not changing the broomstick. You're continually making tiny corrections that prevent small disturbances from growing into larger problems.</p>
<p>Dynamical decoupling works in a similar way.</p>
<p>While a qubit is temporarily idle, carefully chosen pulse sequences are applied to help reduce the effects of environmental noise and preserve its quantum state for longer.</p>
<p>The underlying theory has been studied for decades and has become one of the foundational techniques in quantum error suppression research.</p>
<p>However, applying these techniques hasn't always been straightforward.</p>
<p>Developers often needed specialized knowledge to determine when and where these pulse sequences should be inserted into a circuit.</p>
<p>For many software developers, that level of hardware expertise sits well outside their day-to-day workflow.</p>
<h3 id="heading-where-orbit-fits">Where Orbit Fits</h3>
<p>This brings us to the motivation behind <strong>Orbit</strong>.</p>
<p>Rather than expecting every developer to become an expert in dynamical decoupling and other advanced error suppression techniques, Orbit is designed to make those capabilities more accessible through a familiar Qiskit workflow.</p>
<p>Conceptually, the workflow changes from this:</p>
<pre><code class="language-text">Write Circuit
     ↓
Manually Analyze Idle Periods
     ↓
Design Error Suppression Strategy
     ↓
Modify Circuit
     ↓
Execute on Hardware
</code></pre>
<p>to something much simpler:</p>
<pre><code class="language-text">Write Circuit
     ↓
Orbit Applies Error Suppression
     ↓
Execute on Hardware
</code></pre>
<p>Notice what hasn't changed. You still design your quantum algorithm. You still write your Qiskit circuit. You still execute it on quantum hardware.</p>
<p>The difference is that the error suppression strategy can become part of the workflow instead of another manual optimization task.</p>
<p>In other words, Orbit isn't trying to replace Qiskit.</p>
<p>It's designed to help developers get more reliable results from the quantum circuits they already know how to build.</p>
<h2 id="heading-how-automated-error-suppression-fits-into-a-modern-quantum-workflow">How Automated Error Suppression Fits into a Modern Quantum Workflow</h2>
<p>By this point, we've established two important ideas.</p>
<p>First, today's quantum computers are inherently noisy. As circuits become larger and more complex, even small hardware imperfections accumulate and reduce the quality of the final results.</p>
<p>Second, developers have two broad ways to deal with that noise: <strong>error mitigation</strong>, which improves results after execution, and <strong>error suppression</strong>, which attempts to reduce errors while the circuit is running.</p>
<p>The obvious question now is:</p>
<blockquote>
<p><strong>How do developers actually apply error suppression in practice?</strong></p>
</blockquote>
<p>Historically, the answer hasn't been particularly simple.</p>
<p>Many error suppression techniques require a deep understanding of quantum hardware. Developers often need to analyze their circuits, identify where qubits remain idle, experiment with different optimization strategies, and repeatedly execute the circuit to determine which approach produces the best results.</p>
<p>That process can be both time-consuming and highly specialized.</p>
<p>Even worse, a strategy that improves one circuit may provide little benefit for another.</p>
<p>As Quantum Elements explains in its recent technical blog, developers often end up repeating a cycle of testing, tuning, and rerunning experiments because there isn't a one-size-fits-all solution to quantum noise.</p>
<h3 id="heading-moving-from-manual-optimization-to-automated-workflows">Moving from Manual Optimization to Automated Workflows</h3>
<p>Modern software development has steadily moved toward automation.</p>
<p>We use formatters instead of manually adjusting indentation. We use linters instead of searching for style issues ourselves. We use CI/CD pipelines instead of deploying applications by hand.</p>
<p>Quantum software is beginning to follow the same pattern.</p>
<p>Instead of asking every developer to become an expert in hardware-aware optimization techniques, newer tools aim to automate parts of that workflow while allowing developers to continue writing standard Qiskit circuits.</p>
<p>One example is <strong>Orbit</strong>, which Quantum Elements recently made available as a <strong>Qiskit Function</strong> for IBM Quantum Network members.</p>
<p>Conceptually, the workflow changes from something like this:</p>
<pre><code class="language-text">Write Quantum Circuit
        ↓
Study Hardware Characteristics
        ↓
Experiment with Error Suppression
        ↓
Modify Circuit
        ↓
      Execute
</code></pre>
<p>To a simpler workflow:</p>
<pre><code class="language-text">Write Quantum Circuit
        ↓
Apply Automated Error Suppression
        ↓
      Execute
</code></pre>
<p>The important thing to notice is that <strong>your algorithm doesn't change</strong>.</p>
<p>You still design the circuit and write Qiskit code. The goal is to make advanced optimization techniques easier to integrate into an existing development workflow.</p>
<h3 id="heading-what-orbit-publicly-says-it-does">What Orbit Publicly Says It Does</h3>
<p>Quantum Elements has shared a high-level overview of how Orbit works without disclosing its proprietary implementation.</p>
<p>Orbit accepts an existing Qiskit circuit through the Qiskit Functions interface and prepares it for execution by applying a combination of techniques that may include:</p>
<ul>
<li><p>circuit-level optimization during transpilation,</p>
</li>
<li><p>measurement error mitigation, and</p>
</li>
<li><p>advanced <strong>dynamical decoupling</strong> sequences inserted during idle periods where qubits would otherwise accumulate additional noise.</p>
</li>
</ul>
<p>Notice that none of these techniques require developers to redesign their algorithms from scratch.</p>
<p>Instead, the emphasis is on improving how an existing circuit executes on today's quantum hardware.</p>
<p>Exactly how those optimizations are chosen internally is part of Orbit's implementation, but from a developer's perspective the workflow remains familiar:</p>
<ol>
<li><p>Build your quantum circuit.</p>
</li>
<li><p>Submit it through the supported workflow.</p>
</li>
<li><p>Execute the optimized circuit on compatible IBM Quantum hardware.</p>
</li>
</ol>
<h3 id="heading-a-real-hardware-example">A Real Hardware Example</h3>
<p>So far, you've seen how noise affects a simple Bell-state circuit. But the real challenge appears when circuits become larger and qubits spend more time waiting for other operations to finish.</p>
<p>That's exactly the kind of situation Quantum Elements used in a recent public benchmark for Orbit.</p>
<p>In the experiment, the circuit was executed on IBM's ibm_aachen quantum processor. The goal wasn't to show a completely different quantum algorithm. It was to test what happens when a circuit contains more operations, more waiting periods, and more opportunities for noise to accumulate.</p>
<p>As circuits grow, some qubits often remain idle while other qubits are being measured or processed. Earlier in this article, you learned that idle qubits don't freeze in time. They continue interacting with their environment, and that interaction can gradually destroy the quantum information you're trying to preserve.</p>
<p>According to Quantum Elements' published benchmark, Orbit applies error-suppression techniques during these idle periods and combines them with other circuit-level optimizations.</p>
<p>The company compared three versions of the same workload:</p>
<ul>
<li><p>a standard implementation,</p>
</li>
<li><p>a dynamic implementation without additional protection, and</p>
</li>
<li><p>the dynamic implementation with Orbit enabled.</p>
</li>
</ul>
<p>The reported results showed that the protected version maintained stronger performance across multiple runs on ibm_aachen.</p>
<p>Quantum Elements also reported an increase in the effective qubit lifetime for this particular experiment, which allowed larger versions of the circuit to remain usable for longer.</p>
<p>The important takeaway isn't that every quantum circuit will improve by the same amount.</p>
<p>The more useful lesson is the one you've been building throughout this tutorial:</p>
<p>As quantum circuits become larger and qubits spend more time idle, reducing the accumulation of noise becomes just as important as designing the algorithm itself.</p>
<p>That's why automated error suppression is becoming an increasingly interesting part of modern quantum software workflows. Instead of manually analyzing every idle period and tuning every optimization yourself, tools such as Orbit aim to make those hardware-aware improvements easier to apply to circuits you've already written in Qiskit.</p>
<h3 id="heading-should-you-use-orbit">Should You Use Orbit?</h3>
<p>If you're just beginning your quantum-computing journey, probably not yet.</p>
<p>Your time is better spent learning how quantum circuits work, becoming comfortable with Qiskit, and understanding concepts such as superposition, entanglement, quantum noise, and circuit depth.</p>
<p>However, once you start running larger circuits on IBM Quantum hardware, you'll likely encounter situations where noise becomes a practical limitation rather than just a theoretical concept.</p>
<p>That's the kind of workflow automated error-suppression tools are designed to support.</p>
<p>At the time of writing, Quantum Elements is offering developers <strong>three months of complimentary access</strong> to Orbit for eligible users through a request process. If you're already experimenting with IBM Quantum hardware and would like to evaluate how automated error suppression fits into your workflow, you can request access from <a href="https://quantumelements.ai/orbit-access">Quantum Elements</a>.</p>
<p>Whether you eventually use Orbit or another solution, the bigger lesson remains the same:</p>
<p>Writing a correct quantum algorithm is only part of the challenge. Learning how that algorithm behaves on real quantum hardware — and learning how to reduce the impact of noise — is becoming an increasingly important skill for every quantum developer.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Open Source SaaS Landing Page Template with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repeti ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-landing-page-nextjs-shadcn/</link>
                <guid isPermaLink="false">6a70e0650d58f4d80d2eca59</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcnui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:39:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/33d9aa05-3187-4d07-8aea-bcd83fe13ac0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repetition, not engineering.</p>
<p>So I built and open-sourced a complete SaaS landing page template called <a href="https://www.shadcndeck.com/templates/chatdeck-saas-landing-page">ChatDeck</a>. It runs on Next.js 16, React 19, shadcn/ui with the new <code>base-nova</code> style, Tailwind CSS v4, and TypeScript. The full source is on GitHub under the MIT license. I built and open-sourced this template, and everything here comes from decisions made during that process.</p>
<p>Building it forced me to make real decisions on a stack that moved significantly in the past 12 months. This article is about those decisions: what worked, what didn't, and what I'd do differently if I started today.</p>
<p><strong>Prerequisites:</strong> This article assumes you're comfortable with React and TypeScript. Some familiarity with the Next.js App Router is helpful but not required. Each lesson is explained from first principles.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</a></p>
</li>
<li><p><a href="#heading-getting-started">Getting Started</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's Newbase-novaStyle Changes What "Accessible" Means</a></p>
</li>
<li><p><a href="#heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</a></p>
</li>
<li><p><a href="#heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</a></p>
</li>
<li><p><a href="#heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</a></p>
</li>
<li><p><a href="#heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</a></p>
</li>
<li><p><a href="#heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</a></p>
</li>
<li><p><a href="#heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</a></p>
</li>
<li><p><a href="#heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</a></p>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</h2>
<p>Before getting into the code, here's what the template runs on. Each choice was deliberate — none of these are defaults you get from <code>create-next-app</code>.</p>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Version</th>
<th>Why I chose it</th>
</tr>
</thead>
<tbody><tr>
<td>Next.js</td>
<td>^16.0.3</td>
<td>App Router gives you React Server Components out of the box. Static sections like Hero and Features render on the server — no client-side JS needed for content that never changes.</td>
</tr>
<tr>
<td>React</td>
<td>19.2.0</td>
<td>React 19 stabilises the <code>use</code> hook and concurrent features. Staying on the latest version means the template doesn't immediately feel stale.</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>^4.13.0 (CLI)</td>
<td>Components are copied into your codebase, not installed as a package. You own the code. No version lock-in, no fighting library defaults when you need to customize.</td>
</tr>
<tr>
<td>Base UI (<code>@base-ui/react</code>)</td>
<td>^1.6.0</td>
<td>shadcn/ui's new <code>base-nova</code> style uses Base UI instead of Radix as its headless primitive layer. It has a smaller peer dependency footprint and tighter ARIA integration. More on this in Lesson 1.</td>
</tr>
<tr>
<td>Tailwind CSS</td>
<td>^4</td>
<td>v4 moves theme configuration from a JavaScript config file into CSS directly. Custom animations, color tokens, and radius scales all live in <code>globals.css</code>. More on this in Lesson 2.</td>
</tr>
<tr>
<td>Motion (<code>motion/react</code>)</td>
<td>^12.23.24</td>
<td>The rebranded Framer Motion. Handles entrance animations on the Hero and scroll-triggered animations on the Features section. Chosen over CSS animations because staggered sequences are much simpler to manage.</td>
</tr>
<tr>
<td>TypeScript</td>
<td>^5</td>
<td>Full type safety throughout. Component props, icon maps, pricing plan objects — all typed. Catches errors at build time, not at runtime.</td>
</tr>
<tr>
<td>Lucide React</td>
<td>^0.553.0</td>
<td>Consistent, well-maintained icon set that works cleanly with Tailwind's <code>size-*</code> utilities. No custom SVG wrangling needed for UI icons.</td>
</tr>
</tbody></table>
<p>The most interesting decisions in this list are the ones that reflect how the ecosystem changed in the past year: Base UI replacing Radix inside shadcn/ui, and Tailwind v4's shift to CSS-first configuration. The lessons below walk through each of these in detail, starting with the choices that had the biggest impact on how the code is actually written.</p>
<h2 id="heading-getting-started">Getting Started</h2>
<p>Before diving into the lessons, here's how to get the project running locally. Having it open alongside this article makes the code examples easier to follow.</p>
<pre><code class="language-bash">git clone https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template.git
cd chatdeck-shadcn-saas-landing-page-template
pnpm install
pnpm dev
</code></pre>
<p>Open <code>http://localhost:3000</code> and you'll see the full landing page running locally.</p>
<p>All section content lives as plain TypeScript arrays inside each Block component. To change the features, edit the <code>features</code> array in <code>FeatureSection.tsx</code>. To change pricing tiers, edit the <code>plans</code> array in <code>PricingSection.tsx</code>. No CMS, no config files — just TypeScript objects.</p>
<p>To customize colors, update the OKLCH values in <code>app/globals.css</code> under the <code>:root</code> block. Change <code>--primary</code> and every button, link, and accent color updates across the entire template.</p>
<p>Deploy to Vercel with a single <code>vercel</code> command or by pushing to GitHub and connecting the repo. Next.js is detected automatically.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's the full directory layout before we go through each part of it:</p>
<pre><code class="language-plaintext">chatdeck/
├── app/
│   ├── globals.css         # Theme tokens + custom animations (Tailwind v4 @theme)
│   ├── layout.tsx          # Root layout — Navbar, Footer, fonts
│   └── page.tsx            # Section imports — 16 lines
├── components/
│   ├── Blocks/             # Page sections (Hero, Features, Pricing, etc.)
│   ├── ui/                 # shadcn/ui components — base-nova style
│   └── navbar.tsx          # Scroll-aware sticky navbar
└── lib/
    └── utils.ts            # cn() helper (clsx + tailwind-merge)
</code></pre>
<p>The key separation is <code>Blocks/</code> vs <code>ui/</code>. The <code>ui/</code> folder holds primitive components — Button, Badge, Accordion — that come from shadcn/ui and rarely change. The <code>Blocks/</code> folder holds page-level sections that are specific to this template and change often. When you're customising, you mostly work in <code>Blocks/</code>. When you upgrade <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn/ui components</a>, you touch <code>ui/</code>.</p>
<p>The lessons below go through specific files in this structure piece by piece: <code>components.json</code> and <code>ui/accordion.tsx</code> in Lesson 1, <code>app/globals.css</code> in Lessons 2 and 3, <code>app/page.tsx</code> in Lesson 4, and the individual Block components in Lessons 5 through 8.</p>
<h2 id="heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's New <code>base-nova</code> Style Changes What "Accessible" Means</h2>
<p>If you've used shadcn/ui before, you know the default setup uses <strong>Radix UI</strong> primitives, headless components that handle focus management, keyboard navigation, and ARIA attributes. Radix has been the default for years.</p>
<p>But shadcn/ui introduced a new style in 2025 called <code>base-nova</code>, which replaces <a href="https://www.shadcndeck.com/blog/radix-vs-base-ui">Radix with <strong>Base UI</strong></a>, the headless primitive library from MUI.</p>
<p>Based on shadcn's public direction and the components released through 2025, <code>base-nova</code> appears to be the intended default going forward (though shadcn hasn't yet deprecated the Radix style).</p>
<p>In the project's <code>components.json</code>:</p>
<pre><code class="language-json">{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "iconLibrary": "lucide"
}
</code></pre>
<p>The <code>"style": "base-nova"</code> line means every component the shadcn/ui CLI installs wraps Base UI primitives instead of Radix. To understand what this changes in practice, here's what the same Accordion trigger component looks like in the older Radix-based default style:</p>
<pre><code class="language-tsx">// Radix-based default style (the old way)
import * as AccordionPrimitive from "@radix-ui/react-accordion"

const AccordionTrigger = React.forwardRef&lt;
  React.ElementRef&lt;typeof AccordionPrimitive.Trigger&gt;,
  React.ComponentPropsWithoutRef&lt;typeof AccordionPrimitive.Trigger&gt;
&gt;(({ className, children, ...props }, ref) =&gt; {
  const [isOpen, setIsOpen] = React.useState(false)

  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        ref={ref}
        className={cn("flex flex-1 items-center justify-between ...", className)}
        onClick={() =&gt; setIsOpen(!isOpen)}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "hidden" : "block"
          )}
        /&gt;
        &lt;ChevronUpIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "block" : "hidden"
          )}
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
})
</code></pre>
<p>Notice the <code>useState(false)</code> tracking whether the accordion is open, and the <code>onClick</code> handler that toggles it. This means the component has to manually keep its own <code>isOpen</code> state in sync with what Radix internally knows about the open/closed state.</p>
<p>Now here's the same component using the <code>base-nova</code> style with Base UI:</p>
<pre><code class="language-tsx">// components/ui/accordion.tsx — base-nova style (the new way)
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"

function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start ...",
          className
        )}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
        /&gt;
        &lt;ChevronUpIcon
          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
}
</code></pre>
<p>No <code>useState</code>. No <code>onClick</code>. No <code>isOpen</code> variable. The chevron visibility is controlled entirely by <code>group-aria-expanded/accordion-trigger:hidden</code> — a Tailwind class that reads the <code>aria-expanded</code> attribute Base UI sets automatically on the trigger element.</p>
<p><strong>The lesson here:</strong> in the Radix version, you have two parallel systems: the component's own <code>isOpen</code> state, and the ARIA attributes that the library manages separately for screen readers. These can drift out of sync — for example, if the accordion closes via keyboard navigation, the ARIA state updates correctly but your <code>isOpen</code> state doesn't unless you wire up the right callbacks. In the Base UI version, there is only one system. ARIA state IS the state. Tailwind reads it directly. There's nothing to keep in sync and nothing that can drift.</p>
<p><strong>Lesson:</strong> use the primitive library's ARIA attributes as your source of truth for visual state. If your headless component library already sets <code>aria-expanded</code>, <code>aria-selected</code>, or <code>aria-checked</code>, Tailwind can respond to those directly with <code>aria-*</code> variant classes — no parallel JavaScript state needed.</p>
<p>So when you install shadcn/ui today, choose <code>base-nova</code> over the default Radix style. You get tighter Base UI integration, a smaller peer dependency footprint, and components that are more aligned with where the ecosystem is moving.</p>
<h2 id="heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</h2>
<p>Tailwind CSS v4 moves primary theme configuration out of the JavaScript config file and into CSS. This sounds small. In practice, it changes how you think about the entire theming system.</p>
<p>In Tailwind v3, you'd extend the theme in <code>tailwind.config.js</code>:</p>
<pre><code class="language-js">// OLD — tailwind.config.js (v3)
module.exports = {
  theme: {
    extend: {
      animation: {
        marquee: "marquee 40s linear infinite",
      },
      keyframes: {
        marquee: {
          from: { transform: "translateX(0)" },
          to: { transform: "translateX(calc(-100% - var(--gap)))" },
        },
      },
    },
  },
}
</code></pre>
<p>In Tailwind v4, that same configuration lives in your CSS file instead:</p>
<pre><code class="language-css">/* app/globals.css — Tailwind v4 */
@import "tailwindcss";

@theme inline {
  --animate-marquee: marquee var(--duration) infinite linear;
  --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;

  @keyframes marquee {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - var(--gap))); }
  }

  --radius-2xl: calc(var(--radius) * 1.8);
  --radius-3xl: calc(var(--radius) * 2.2);
  --radius-4xl: calc(var(--radius) * 2.6);
}
</code></pre>
<p>The <code>@theme inline</code> block extends Tailwind's design token system. Define <code>--animate-marquee</code> here and you can use <code>className="animate-marquee"</code> anywhere in your components. Tailwind generates the utility class automatically from the CSS variable.</p>
<p>Custom animations, radius scales, and color tokens all live in CSS now. The benefit is that CSS is where styles belong. The config file was always an indirection layer between "what I want my design system to look like" and "where that actually lives." Tailwind v4 removes the indirection.</p>
<p><strong>The friction:</strong> if you start a Tailwind v4 project with a v3 mental model, you'll spend time looking for theme config in the wrong place. Read the v4 migration guide before you start, not after you're confused.</p>
<p><strong>Lesson:</strong> move your mental model of "theme config" from JavaScript to CSS. In Tailwind v4, if you want a custom animation, a new radius scale, or a color token, define it in <code>@theme inline</code> inside <code>globals.css</code>. That's where it belongs, and that's where every developer on your team will find it.</p>
<h2 id="heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</h2>
<p>The template uses OKLCH color values throughout, not hex or HSL:</p>
<pre><code class="language-css">:root {
  --background: oklch(1 0 0);        /* white */
  --foreground: oklch(0.145 0 0);    /* near-black */
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
}

.dark {
  --background: oklch(0.145 0 0);    /* near-black */
  --foreground: oklch(0.985 0 0);    /* near-white */
  --primary: oklch(0.922 0 0);
  --border: oklch(1 0 0 / 10%);      /* white at 10% opacity */
}
</code></pre>
<p>OKLCH is a perceptually uniform color space. When you increase the lightness value in OKLCH, the color actually <em>looks</em> lighter to human eyes, consistently. Hex and HSL don't guarantee this. You can increase the <code>L</code> in HSL and get a color that looks the same or even darker depending on the hue.</p>
<p>For dark mode specifically, this matters because you're inverting a whole color system. With HSL, you'll often end up manually tweaking individual color values until contrast ratios look right. With OKLCH, increasing or decreasing the lightness value gives you predictable results across all your tokens.</p>
<p>The dark mode switch itself is <strong>zero JavaScript.</strong> Adding <code>class="dark"</code> to the <code>&lt;html&gt;</code> element swaps every CSS variable. Tailwind reads the updated variables and re-renders every component. There's no context provider and no <code>useTheme</code> hook needed for the CSS layer — just a class toggle on the root element.</p>
<p><strong>Lesson:</strong> swap your color tokens to OKLCH. When defining dark mode values, adjust the first OKLCH parameter (lightness) and the result will look predictably lighter or darker. With hex or HSL you're often guessing; with OKLCH you're reasoning.</p>
<h2 id="heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</h2>
<p>The main page file is 16 lines:</p>
<pre><code class="language-tsx">// app/page.tsx
import Hero from "@/components/Blocks/Hero";
import { LogoCarousel } from "@/components/Blocks/LogoCarousel";
import { FeatureSection } from "@/components/Blocks/FeatureSection";
import { TeamSection } from "@/components/Blocks/TeamSection";
import { TestimonialSection } from "@/components/Blocks/TestimonialSection";
import { PricingSection } from "@/components/Blocks/PricingSection";
import { FaqSection } from "@/components/Blocks/FaqSection";

export default function Home() {
  return (
    &lt;main className="min-h-screen bg-white dark:bg-black"&gt;
      &lt;div className="mx-auto max-w-7xl px-6 pt-40"&gt;
        &lt;Hero /&gt;
        &lt;LogoCarousel /&gt;
        &lt;FeatureSection /&gt;
        &lt;TeamSection /&gt;
        &lt;TestimonialSection /&gt;
        &lt;PricingSection /&gt;
        &lt;FaqSection /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>No dynamic imports, no lazy-loading config, no context providers wrapping everything. Each section is a completely self-contained component in <code>components/Blocks/</code>. None of them import from each other.</p>
<p>This decision came from watching how developers actually use <a href="https://www.shadcndeck.com/templates">shadcn templates</a>. The first thing anyone does after cloning is delete the sections they don't need and reorder the ones they keep. With flat imports, removing the Team section is one deleted line. Reordering sections is moving one line. Adding a new section is creating a file and adding one import.</p>
<p>The alternative (a sections array, a renderer loop, a config file that controls order) sounds sophisticated. In practice, it adds indirection that makes the template harder to understand and slower to customize. Templates should be obvious, not impressive.</p>
<p><strong>Lesson:</strong> in a template context, the simplest architecture is the correct architecture. The developer cloning your template isn't impressed by abstraction. They want to understand the code fast and change it faster.</p>
<h2 id="heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</h2>
<p>The Hero section uses entrance animations where each element fades up sequentially: badge first, then heading, then subheading, then CTA. The naïve approach sets a different <code>delay</code> prop on each element manually. The correct approach uses <code>staggerChildren</code>:</p>
<pre><code class="language-tsx">// components/Blocks/Hero.tsx
"use client"
import { motion, type Variants } from "motion/react"

const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.15,  // each child animates 150ms after the previous
      delayChildren: 0.1,
    },
  },
}

const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: "easeOut" },
  },
}

const Hero = () =&gt; (
  &lt;motion.div variants={containerVariants} initial="hidden" animate="visible"&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* Badge */}
    &lt;/motion.div&gt;
    &lt;motion.h1 variants={fadeUpVariants}&gt;
      AI Chatbot for Customer Support.
    &lt;/motion.h1&gt;
    &lt;motion.p variants={fadeUpVariants}&gt;
      {/* Subheading */}
    &lt;/motion.p&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* CTA */}
    &lt;/motion.div&gt;
  &lt;/motion.div&gt;
)
</code></pre>
<p>The parent defines <code>staggerChildren: 0.15</code>. Every child with <code>variants={fadeUpVariants}</code> automatically inherits a 150ms offset from the previous child. Want to add a new element? Give it <code>variants={fadeUpVariants}</code> and the stagger chain extends automatically. No manually updated delay values.</p>
<p>The Features section uses <strong>scroll-triggered animations</strong> with a different easing:</p>
<pre><code class="language-tsx">// components/Blocks/FeatureSection.tsx
&lt;motion.div
  initial={{ opacity: 0, y: 40 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{
    duration: 0.5,
    delay: index * 0.15,
    ease: [0.22, 1, 0.36, 1],
  }}
&gt;
</code></pre>
<p><code>viewport={{ once: true }}</code> fires the animation once when the element enters the viewport, not on every scroll pass. <code>amount: 0.3</code> starts the animation when 30% of the element is visible, not when the full element is on screen. The cubic bezier <code>[0.22, 1, 0.36, 1]</code> is a fast-out-slow-in curve that feels physical rather than mechanical.</p>
<p><strong>Quick note on the import:</strong> most of the core API is compatible, but <code>motion/react</code> isn't a straight drop-in rename of <code>framer-motion</code>. If you're upgrading an existing project, check the <a href="https://motion.dev/docs/react-upgrade-guide">official migration guide</a> before swapping the import. Layout animations, <code>AnimatePresence</code> behaviour, and some hooks changed.</p>
<p><strong>Lesson:</strong> define animation variants at the parent level and use <code>staggerChildren</code> to orchestrate the sequence. Never set <code>delay</code> manually on individual elements — that creates a brittle list of numbers you have to update every time you add or remove an element. Let the parent handle timing; let children just declare what they animate to.</p>
<h2 id="heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</h2>
<p>The testimonials use a dual-row auto-scrolling marquee. The second row scrolls in reverse. There's no third-party marquee package. It's a small component built entirely on CSS animations defined in Tailwind v4's <code>@theme</code> block.</p>
<pre><code class="language-tsx">// components/ui/marquee.tsx
export function Marquee({
  reverse = false,
  pauseOnHover = false,
  vertical = false,
  children,
  repeat = 4,
  ...props
}) {
  return (
    &lt;div className="group flex gap-(--gap) overflow-hidden [--duration:40s] [--gap:2rem]"&gt;
      {Array(repeat).fill(0).map((_, i) =&gt; (
        &lt;div
          key={i}
          className={cn("flex shrink-0 justify-around gap-(--gap)", {
            "animate-marquee flex-row": !vertical,
            "group-hover:paused": pauseOnHover,
            "[animation-direction:reverse]": reverse,
          })}
        &gt;
          {children}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>repeat={4}</code> prop renders the children 4 times side by side. As the CSS animation scrolls the container left, the repetitions create a seamless loop. By the time the first set has scrolled off screen, the second set is already in position.</p>
<p><code>group-hover:paused</code> is Tailwind applying <code>animation-play-state: paused</code> when the parent has <code>group</code> class and is hovered. No <code>onMouseEnter</code>/<code>onMouseLeave</code> handlers or state, just pure CSS.</p>
<p>To customize the scroll speed without touching the component source, you override the CSS variable inline:</p>
<pre><code class="language-tsx">&lt;Marquee pauseOnHover className="[--duration:20s]"&gt;
  {items.map(item =&gt; &lt;Card key={item.id} {...item} /&gt;)}
&lt;/Marquee&gt;
</code></pre>
<p><code>[--duration:20s]</code> is a Tailwind arbitrary property. It sets <code>--duration</code> directly on the element, which the animation reads via <code>var(--duration)</code>. Speed customization without a prop, without touching the component.</p>
<p><strong>Lesson:</strong> before reaching for a third-party animation package, check whether a CSS keyframe animation and a couple of Tailwind utilities can do the same job. A marquee, a fade loop, a pulsing skeleton — all of these are achievable with native CSS. Fewer dependencies means fewer breaking changes when the ecosystem moves.</p>
<h2 id="heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</h2>
<p>The pricing section has three cards: Free, Pro, and Business. Each card has four rows: plan name, price, CTA button, and features list. The features list height varies between plans. Without CSS subgrid, the rows don't align across cards.</p>
<p>The common workaround is <code>min-height</code> on each row, or JavaScript that measures each card and sets explicit heights. Both approaches are fragile. Subgrid solves it in CSS:</p>
<pre><code class="language-tsx">// components/Blocks/PricingSection.tsx
&lt;div className="grid lg:grid-cols-3"&gt;
  {plans.map((plan) =&gt; (
    &lt;div className="p-8 grid grid-rows-subgrid row-span-4 gap-6"&gt;
      &lt;div&gt;{/* Plan name + description */}&lt;/div&gt;
      &lt;div&gt;{/* Price */}&lt;/div&gt;
      &lt;div&gt;{/* CTA button */}&lt;/div&gt;
      &lt;div&gt;{/* Features list */}&lt;/div&gt;
    &lt;/div&gt;
  ))}
&lt;/div&gt;
</code></pre>
<p><code>grid-rows-subgrid</code> tells each card to participate in the parent grid's row tracks rather than creating its own. Each card spans 4 rows (<code>row-span-4</code>). The plan name row, price row, CTA row, and features row align across all three cards (regardless of content height) because they're all on the same row tracks.</p>
<p>Each card's <code>row-span-4</code> reserves four rows in the parent's implicit grid. Because every card spans the same four shared row tracks, their internal rows align automatically even though the parent never declares explicit row heights.</p>
<p>CSS subgrid has been in all modern browsers since late 2023. There's no reason to reach for a JavaScript layout solution when the platform handles it.</p>
<p><strong>Lesson:</strong> when you have a grid of cards where each card has multiple internal rows that need to align across columns, reach for <code>grid-rows-subgrid</code> before reaching for <code>min-height</code> or JavaScript. Define the number of rows each card spans with <code>row-span-N</code>, and the browser handles the rest.</p>
<h2 id="heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</h2>
<p>The logo carousel renders 12 brand logos: Shopify, Stripe, GitHub, Google, and others. The first instinct is to use a package like <code>react-icons</code> or <code>simple-icons</code>. I went a different direction: inline SVG paths stored as a plain TypeScript object.</p>
<pre><code class="language-tsx">// components/Blocks/LogoCarousel.tsx
const iconMap = {
  stripe: "M13.976 9.15c-2.172-.806...",
  github: "M12 .297c-6.63 0-12...",
  google: "M12.48 10.92v3.28h7.84...",
  // ...
} as const

const SimpleIcon = ({ iconSlug, size = 24 }: { iconSlug: string; size?: number }) =&gt; {
  const iconPath = iconMap[iconSlug as keyof typeof iconMap]
  return (
    &lt;svg role="img" viewBox="0 0 24 24" className="fill-black dark:fill-white"&gt;
      &lt;path d={iconPath} /&gt;
    &lt;/svg&gt;
  )
}
</code></pre>
<p>The <code>fill-black dark:fill-white</code> class means every logo automatically inverts in dark mode: no separate dark mode logo assets, and no conditional rendering based on theme.</p>
<p>The carousel itself duplicates the logo array to create a seamless loop:</p>
<pre><code class="language-tsx">{/* First pass */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`first-${i}`} {...company} /&gt;)}
{/* Second pass — identical, creates the seamless loop */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`second-${i}`} {...company} /&gt;)}
</code></pre>
<p>The CSS animation (<code>animate-logo-scroll</code>) scrolls the container left. When the first pass disappears off the left edge, the second pass is already in position. The loop is seamless.</p>
<p><strong>The trade-off:</strong> maintaining SVG paths manually is fine for a fixed set of logos. If you need a large dynamic icon set, reach for <code>simple-icons</code> or a proper icon library. For 12 brand logos that rarely change, this approach ships zero extra dependencies.</p>
<p><strong>Lesson:</strong> match your tooling to your actual requirements. A logo carousel with a fixed set of brand logos doesn't need an icon library — it needs a TypeScript object and two Tailwind classes. Installing a package to solve a problem you could solve with 10 lines of code adds maintenance surface for no gain.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<p>These are the three decisions I'd change if starting the template today.</p>
<h3 id="heading-1-extract-animation-variants-to-a-shared-file">1. Extract Animation Variants to a Shared File</h3>
<p><code>containerVariants</code> and <code>fadeUpVariants</code> are currently defined locally in both <code>Hero.tsx</code> and <code>FeatureSection.tsx</code>. If you want to change the global animation timing (say, reduce duration from 0.5s to 0.3s) you update two files. A shared <code>lib/animations.ts</code> exporting the standard variants would make global timing changes a one-line edit.</p>
<pre><code class="language-ts">// lib/animations.ts
export const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
}

export const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { staggerChildren: 0.15, delayChildren: 0.1 } },
}
</code></pre>
<h3 id="heading-2-use-subgrid-in-the-features-section-too">2. Use Subgrid in the Features Section Too</h3>
<p>The Features grid uses a border-based visual separation pattern — borders between cells create the grid appearance. It works, but the hover states have an inconsistency: the gradient hover overlay height varies slightly between cells in the same row because content heights differ. Subgrid would lock those row heights across cards the same way it does in the Pricing section.</p>
<h3 id="heading-3-use-nextfont-more-consistently">3. Use <code>next/font</code> More Consistently</h3>
<p>The layout loads both Geist and Inter font families. Inter is used via <code>--font-sans</code>. Geist is loaded but the <code>geistSans.variable</code> and <code>geistMono.variable</code> are applied to <code>&lt;body&gt;</code> as className strings while Inter drives the actual font rendering through the CSS variable. The result is that Geist is loaded but not actually displayed. Cleaning this up could shave tens of kilobytes from the font payload — worth verifying in Lighthouse or the Network tab before deploying.</p>
<h2 id="heading-summary">Summary</h2>
<p>These are the five things from this build worth taking into your next project:</p>
<ol>
<li><p><strong>shadcn/ui's</strong> <code>base-nova</code> <strong>style</strong> runs on Base UI primitives. ARIA state drives visual state — no parallel JavaScript state needed.</p>
</li>
<li><p><strong>Tailwind v4 moves theme config to CSS.</strong> All theme tokens, custom animations, and radius scales live in CSS via <code>@theme inline</code>. This is the right place for them.</p>
</li>
<li><p><strong>OKLCH gives predictable dark mode contrast.</strong> Adjusting lightness in OKLCH actually changes perceived brightness. Hex and HSL don't guarantee this.</p>
</li>
<li><p><code>staggerChildren</code> <strong>in motion/react</strong> eliminates manually managed animation delays. The parent orchestrates while the children just declare their animation variant.</p>
</li>
<li><p><strong>CSS subgrid (</strong><code>grid-rows-subgrid</code><strong>)</strong> aligns card rows across columns natively. No JavaScript measurement, no fixed heights.</p>
</li>
</ol>
<p>The full template is MIT-licensed and available at <a href="https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template">github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template</a>. If it's useful, a star helps others find it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What Happens During a Production Deployment? A Behind-the-Scenes Guide ]]>
                </title>
                <description>
                    <![CDATA[ You push your code. A few minutes later, it is live for real users. Between those two moments runs a long chain of machinery: builds, artefacts, migrations, health checks, traffic shifts. Every produc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-happens-during-a-production-deployment/</link>
                <guid isPermaLink="false">6a70dada6358084ff948ee0f</guid>
                
                    <category>
                        <![CDATA[ deployment ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:15:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/43567dce-ecbe-412e-ab40-2ef6e07dde0e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You push your code. A few minutes later, it is live for real users.</p>
<p>Between those two moments runs a long chain of machinery: builds, artefacts, migrations, health checks, traffic shifts. Every production engineer depends on that chain, and many teams still build and operate it themselves.</p>
<p>Deployment infrastructure has quietly become operational overhead. It started as a technical necessity, something every team had to assemble because nothing else existed.</p>
<p>Today it is a second system your engineers maintain alongside the product, consuming on-call rotations, sprint capacity, and 2 a.m. attention that could go somewhere better.</p>
<p>In this article, we'll walk through each stage of a real production deployment: the build, the artefact it produces, database migrations, health checks, rolling updates, and rollbacks. Along the way, we'll look at why <a href="https://www.freecodecamp.org/news/my-team-s-experience-moving-from-aws-to-a-paas/">platform-as-a-service (PaaS)</a> tools handle most of these steps for you, and what it costs a team to keep handling them itself.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-build-turning-code-into-something-that-can-run">The Build: Turning Code into Something That Can Run</a></p>
</li>
<li><p><a href="#heading-the-artefact-one-version-frozen-in-time">The Artefact: One Version, Frozen in Time</a></p>
</li>
<li><p><a href="#heading-database-migrations-the-riskiest-step">Database Migrations: The Riskiest Step</a></p>
</li>
<li><p><a href="#heading-health-checks-proving-the-new-version-is-alive">Health Checks: Proving the New Version Is Alive</a></p>
</li>
<li><p><a href="#heading-rolling-updates-replacing-the-planes-engine-mid-flight">Rolling Updates: Replacing the Plane's Engine Mid-Flight</a></p>
</li>
<li><p><a href="#heading-rollbacks-the-escape-hatch">Rollbacks: The Escape Hatch</a></p>
</li>
<li><p><a href="#heading-when-you-dont-need-a-paas">When You Don't Need a PaaS</a></p>
</li>
<li><p><a href="#heading-should-you-still-be-running-this-yourself">Should You Still Be Running This Yourself?</a></p>
</li>
</ul>
<h2 id="heading-the-build-turning-code-into-something-that-can-run"><strong>The Build: Turning Code into Something That Can Run</strong></h2>
<p>A deployment does not ship your source code as-is. It ships the result of a build. The build stage takes your code and turns it into something a server can run.</p>
<p>What this looks like depends on your stack. A Java or Go project gets compiled into a binary. A JavaScript front end gets bundled and minified. A Python app gets its dependencies resolved and pinned. In most modern setups, all of this gets packed into a <a href="https://www.freecodecamp.org/news/an-introduction-to-docker-and-containers-for-beginners/">container image</a>, which is a frozen snapshot of your app plus everything it needs to run.</p>
<p>The build stage also runs your tests. Unit tests, linting, and security scans all happen here. If any of them fail, the deployment stops before it can touch production. This is the cheapest place to catch a bug. A failed build costs you a few minutes. A failed deployment can cost you customers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/9a8b5d93-802c-4898-bd07-91a90041f93a.svg" alt="stages of code deployment" style="display:block;margin:0 auto" width="680" height="520" loading="lazy">

<p>Teams that run their own pipelines spend real effort here. They maintain build servers, cache dependencies, and debug flaky test runners.</p>
<p>None of that work ships a feature. It is pure upkeep, and it never ends. A PaaS bakes this whole stage into the platform. You push code, and the platform detects your language, builds it the same way every time, and fails fast when something is wrong. The build still happens. Your engineers just stop paying for it in hours.</p>
<h2 id="heading-the-artefact-one-version-frozen-in-time"><strong>The Artefact: One Version, Frozen in Time</strong></h2>
<p>The output of a build is called an artefact. It might be a container image, a compiled binary, or a zipped bundle. Whatever the format, the artefact has one job: to be exact. It represents one precise version of your app, frozen at one point in time.</p>
<p>This matters more than it sounds. The artefact that passed your tests must be the exact same one that reaches production. If you rebuild between testing and shipping, you risk shipping something slightly different. A dependency may have updated. A build flag may have changed. "It worked in staging" often means "we built it twice and got two different results."</p>
<p>Good pipelines build once and promote the same artefact through every stage. Artefacts get versioned and stored in a registry, so any version can be pulled and run again later. That stored history is also what makes rollbacks possible, which we will get to soon.</p>
<p>On a PaaS, artefact handling is standard practice by default. Every deploy produces a numbered release. The platform stores it, tracks it, and can restore it. You do not have to design a registry strategy, write promotion scripts, or assign an engineer to own them. The discipline is built in.</p>
<h2 id="heading-database-migrations-the-riskiest-step"><strong>Database Migrations: The Riskiest Step</strong></h2>
<p>Before new code goes live, the database often has to change with it. Maybe the new version needs a new column or a new table. These changes are called migrations, and they are the most dangerous part of most deployments.</p>
<p>Why? Code is easy to replace. Data is not. If you deploy a bad code version, you can swap it out. If a migration corrupts or drops data, there may be no clean way back. Migrations also create a tricky window of time. For a few minutes, old code and new code may run against the same database at once. Both versions have to work with the schema during that window.</p>
<p>The safe pattern is to make migrations backwards-compatible. Add the new column first, deploy code that can handle both shapes, then clean up the old column in a later release. It takes more steps, but each step is safe on its own.</p>
<p>A PaaS cannot write your migrations for you. No tool can know what your data means. But a good platform gives migrations a defined place in the release process, runs them in order, and logs exactly what ran and when. That structure prevents the classic failure where someone runs a migration by hand and forgets to tell the team.</p>
<h2 id="heading-health-checks-proving-the-new-version-is-alive"><strong>Health Checks: Proving the New Version Is Alive</strong></h2>
<p>Once the new version starts, the platform does not just trust it. It checks. A health check is a small endpoint in your app, often just a route that returns "OK." The platform calls it over and over. If the app answers, it is considered healthy. If it does not, the platform assumes something is wrong.</p>
<p>There are usually two kinds of checks. A readiness check asks, "Are you ready to receive traffic?" A liveness check asks, "Are you still working, or should I restart you?" The difference matters. An app can be alive but not ready, such as when it is still warming up a cache.</p>
<p>Health checks are the gatekeepers of a deployment. No traffic reaches a new version until it proves it can handle requests. Without them, you would be routing real users to an app that might still be crashing on startup.</p>
<p>Every serious PaaS runs health checks automatically. You define the endpoint, and the platform handles the polling, the timeouts, and the decisions. Teams that build this themselves tune all of those settings by hand, and they usually learn the right values through painful trial and error. That tuition is paid in engineering time, on a problem the industry solved years ago.</p>
<h2 id="heading-rolling-updates-replacing-the-planes-engine-mid-flight"><strong>Rolling Updates: Replacing the Plane's Engine Mid-Flight</strong></h2>
<p>Here is the hard part. Your old version is serving live traffic right now. You need to replace it without dropping a single request. The most common answer is a <a href="https://kubernetes.io/docs/tutorials/kubernetes-basics/update/update-intro/">rolling update</a>.</p>
<p>It works like this. Say you have four copies of your app running. The platform starts one copy of the new version and waits for its health checks to pass. Then it shifts a slice of traffic to it and shuts down one old copy. It repeats this, one copy at a time, until only the new version remains. Users never notice, because at every moment there are enough healthy copies to serve everyone.</p>
<p>Some teams use variations of this idea. A blue-green deployment runs the full new version beside the old one, then flips all traffic at once. A canary release sends a tiny share of users to the new version first, watching for errors before going wider.</p>
<p>Doing this by hand means writing orchestration logic, managing load balancer rules, and handling every edge case where a step fails halfway. That is months of engineering effort to build and a permanent tax to maintain, all for behavior a PaaS ships as the default. On a platform, you get zero-downtime releases out of the box, not as a project your team has to staff.</p>
<h2 id="heading-rollbacks-the-escape-hatch"><strong>Rollbacks: The Escape Hatch</strong></h2>
<p>Sometimes the new version passes every check and still breaks something real. An error rate climbs. A page loads blank. Now speed matters more than anything, and the fastest fix is rarely a new patch. It is a rollback: redeploying the previous artefact that you already know works.</p>
<p>This is why frozen, versioned artefacts are so important. A rollback is only fast if the old version is stored, tested, and ready to run. Teams that rebuild from an old commit under pressure are gambling at the worst possible time.</p>
<p>On most PaaS platforms, a rollback is one command or one click. The platform keeps your release history and can restore any previous version in seconds. That single feature has saved more on-call engineers' nights than perhaps any other.</p>
<h2 id="heading-when-you-dont-need-a-paas">When You Don't Need a PaaS</h2>
<p>The case for handing deployment to a platform is strong, but it isn't universal. There are teams for whom owning the pipeline is not overhead; it is a deliberate and justified engineering decision.</p>
<h3 id="heading-when-compliance-demands-it">When Compliance Demands It</h3>
<p>Regulated industries like finance, healthcare, government, often operate under requirements that a standard PaaS cannot satisfy out of the box. Data residency rules may dictate exactly which physical infrastructure your builds touch. Audit requirements may demand a level of provenance and access logging that a managed platform doesn't expose.</p>
<p>Security controls may need to extend into the build environment itself, not just the runtime. In these contexts, the cost of owning the pipeline is real, but it is the cost of operating in that industry.</p>
<h3 id="heading-when-deployment-is-your-product">When Deployment is Your Product</h3>
<p>If your company sells deployment infrastructure, a CI/CD platform, a release orchestration tool, an internal developer platform, then your pipeline is not overhead at all. It is the product.</p>
<p>The engineers maintaining it are doing product work, not distraction work. The same applies to platform engineering teams at large organizations whose explicit charter is to build and own the deployment layer for dozens of other internal teams. In both cases, the question of "why are we running this ourselves" has an obvious answer: because this is what we do.</p>
<h3 id="heading-when-your-infrastructure-is-genuinely-unusual">When Your Infrastructure is Genuinely Unusual</h3>
<p>Most PaaS platforms are optimized for stateless web services and standard container workloads. If your system falls outside that envelope, GPU clusters, real-time systems with strict latency requirements, hybrid on-premise and cloud deployments, hardware-in-the-loop testing, a general-purpose platform may simply not fit.</p>
<p>Shoehorning an unusual workload into a PaaS often produces more friction than building narrow, purpose-built deployment tooling around the specific constraints you actually have.</p>
<p>The common thread across all three cases is specificity. The teams that are right to own their pipelines can usually state clearly why a platform doesn't fit. If the answer is "we've always done it this way" or "we like having control," that's worth questioning. If the answer is "our compliance requirements mandate X" or "we sell this," that's a reason.</p>
<h2 id="heading-should-you-still-be-running-this-yourself">Should You Still Be Running This Yourself?</h2>
<p>A PaaS does not make any of these steps disappear. The build still runs. Artefacts still get stored. Migrations still execute, health checks still poll, and traffic still shifts one copy at a time. Abstracting these mechanics does not eliminate them. It standardizes them, and pushes their maintenance onto a team whose entire product is deployment.</p>
<p>That is the question every product team should now ask plainly: why are we still building and operating this machinery ourselves? A decade ago, a custom pipeline was unavoidable. Today it is a choice, and for most teams it is the wrong one. Every hour spent debugging a flaky build agent, tuning a health check timeout, or patching orchestration scripts is an hour taken from the product your customers actually pay for. The pipeline does not differentiate you. It cannot. Your competitors' deploys work the same way yours do.</p>
<p>Know how the chain works, because on-call at 2 a.m. demands it. But knowing how it works is not a reason to own it. "We built our own deployment system" is not a badge of honor anymore. It is an admission that your team maintains a second product with no customers. Unless deployment infrastructure is your business, hand the machinery to a platform, and put your engineers back on the work only they can do.</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[ What is Agentic AI? How AI Is Evolving From Chatbot to Co-Worker ]]>
                </title>
                <description>
                    <![CDATA[ You ask ChatGPT a question. It answers. You ask another. It answers again. That back-and-forth has been the standard way most people experience AI: a smart, fast assistant that responds when spoken to ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-is-agentic-ai-from-chatbot-to-co-worker/</link>
                <guid isPermaLink="false">6a6cc10945a46b452b5bc4a1</guid>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatbot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Fri, 31 Jul 2026 15:36:41 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/66ea50ee-f208-47c2-a35b-cd6823464f21.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You ask ChatGPT a question. It answers. You ask another. It answers again. That back-and-forth has been the standard way most people experience AI: a smart, fast assistant that responds when spoken to.</p>
<p>But something big is changing. AI is no longer just responding. It is planning, deciding, and acting on its own. This new kind of AI is called <strong>agentic AI</strong>, and it is quickly becoming one of the most important shifts in technology today.</p>
<p>In this article, we'll break down what agentic AI is, how it works, where it is being used, and what risks it brings along.</p>
<h2 id="heading-what-well-cover"><strong>What We'll Cover</strong></h2>
<ul>
<li><p><a href="#what-does-agentic-even-mean">What Does "Agentic" Even Mean?</a></p>
</li>
<li><p><a href="#how-a-chatbot-works-vs-how-an-agent-works">How a Chatbot Works vs. How an Agent Works</a></p>
</li>
<li><p><a href="#a-real-example-booking-a-business-trip">A Real Example: Booking a Business Trip</a></p>
</li>
<li><p><a href="#the-building-blocks-of-an-ai-agent">The Building Blocks of an AI Agent</a></p>
</li>
<li><p><a href="#why-is-this-happening-now">Why Is This Happening Now?</a></p>
</li>
<li><p><a href="#where-agentic-ai-is-being-used-today">Where Agentic AI Is Being Used Today</a></p>
</li>
<li><p><a href="#what-are-the-risks">What Are the Risks?</a></p>
</li>
<li><p><a href="#what-this-means-for-you">What This Means for You</a></p>
</li>
</ul>
<h2 id="heading-what-does-agentic-even-mean">What Does "Agentic" Even Mean?</h2>
<p>The word comes from "agency": the ability to act independently toward a goal.</p>
<p>A regular chatbot waits for you to ask something. An agentic AI system is given a goal and then figures out the steps needed to reach it. It can use tools, browse the web, write and run code, send emails, and loop back to fix its own mistakes, without you guiding every move.</p>
<p>Think of the difference this way: A chatbot is like a very knowledgeable colleague who only speaks when spoken to. An AI agent is like giving that colleague a task and saying, "Handle this for me," then walking away.</p>
<h2 id="heading-how-a-chatbot-works-vs-how-an-agent-works">How a Chatbot Works vs. How an Agent Works</h2>
<p>To understand agentic AI, it helps to see the difference in action.</p>
<p>A chatbot follows a simple loop:</p>
<pre><code class="language-plaintext">User types message → AI reads it → AI generates a reply → Done
</code></pre>
<p>An AI agent follows a much more complex loop:</p>
<pre><code class="language-plaintext">User gives a goal
  → Agent breaks it into steps
  → Agent picks a tool (web search, code runner, email, etc.)
  → Agent takes action
  → Agent checks the result
  → If result is wrong or incomplete, agent adjusts and tries again
  → Agent moves to the next step
  → Repeats until the goal is achieved
</code></pre>
<p>That ability to plan, act, check, and retry is what makes agentic AI fundamentally different. It is not just predicting the next word in a sentence. It is running a small project.</p>
<h2 id="heading-a-real-example-booking-a-business-trip">A Real Example: Booking a Business Trip</h2>
<p>Here is a concrete example to make this tangible.</p>
<p>You tell an AI agent: <em>"Book me the cheapest flight to Mumbai next Monday, find a hotel near the conference centre, and add both to my calendar."</em></p>
<p>A chatbot would give you links or suggestions and leave the rest to you.</p>
<p>An AI agent would:</p>
<pre><code class="language-plaintext">Step 1: Search for flights to Mumbai on Monday
Step 2: Compare prices and pick the cheapest option
Step 3: Fill in your passenger details and complete the booking
Step 4: Search for hotels near the conference centre
Step 5: Cross-check availability and price
Step 6: Complete the hotel booking
Step 7: Pull the confirmation details from both bookings
Step 8: Add flight and hotel to your Google Calendar
Step 9: Send you a summary email
</code></pre>
<p>Each of those steps involves calling a different tool or service. The agent handles all of it. You just gave it the goal.</p>
<h2 id="heading-the-building-blocks-of-an-ai-agent">The Building Blocks of an AI Agent</h2>
<p>Every AI agent, no matter how complex, is built on a few core components.</p>
<p><strong>A brain (the language model).</strong> This is the reasoning engine: usually a <a href="https://en.wikipedia.org/wiki/Large_language_model">large language model</a> like <a href="https://en.wikipedia.org/wiki/GPT-4">GPT-4</a> or Claude. It reads the goal, thinks through the plan, and decides what to do next.</p>
<p><strong>Memory.</strong> Agents need to remember what they have already done. Short-term memory keeps track of the current task. Long-term memory lets the agent store information across sessions: so it remembers your preferences from last time.</p>
<p><strong>Tools.</strong> An agent without tools is just a chatbot. Tools are what give agents power. Common tools include web search, code execution, file reading, API calls, email, and calendar access. The agent decides which tool to use and when.</p>
<p><strong>A feedback loop.</strong> After taking an action, the agent checks whether it worked. If a step failed or returned a wrong result, it tries a different approach. This self-correction is what makes agents reliable for multi-step tasks.</p>
<h2 id="heading-why-is-this-happening-now">Why Is This Happening Now?</h2>
<p>Agentic AI is not a brand new idea. Researchers have explored autonomous agents for decades. So why is it suddenly everywhere in 2026?</p>
<p>Three things came together at the right time.</p>
<p>First, language models got dramatically better at reasoning. Earlier models were good at writing text but poor at logical planning. Newer models can break down complex tasks, spot errors in their own output, and change strategy mid-task.</p>
<p>Second, tool integration became much easier. Frameworks like <a href="https://www.langchain.com">LangChain</a>, <a href="https://www.microsoft.com/en-us/research/project/autogen/">AutoGen</a>, and OpenAI's function calling made it straightforward for developers to connect a language model to real-world tools. What once took months of custom engineering now takes days.</p>
<p>Third, businesses started demanding it. Copy-pasting AI suggestions into forms and emails gets old quickly. Companies want AI that completes workflows, not just assists with them.</p>
<h2 id="heading-where-agentic-ai-is-being-used-today">Where Agentic AI Is Being Used Today</h2>
<p>Agentic AI is already showing up across many industries, not just in tech companies.</p>
<p>In software development, AI agents write code, run tests, find bugs, and open pull requests: all from a single instruction like "fix the login error on the checkout page."</p>
<p>In customer support, agents handle entire conversations. They look up order history, process refunds, escalate complex cases to a human, and follow up via email: without a support agent touching the ticket.</p>
<p>In research, agents search dozens of sources, extract key data, cross-reference findings, and produce a summarized report. A task that used to take hours gets done in minutes.</p>
<p>In marketing, agents draft campaign content, schedule social posts, monitor performance metrics, and suggest adjustments based on what is working.</p>
<h2 id="heading-what-are-the-risks">What Are the Risks?</h2>
<p>Agentic AI is powerful, but it comes with real concerns that are worth knowing about.</p>
<p>The biggest one is unintended actions. An agent that misunderstands a goal can take a chain of wrong steps before anyone notices. Unlike a chatbot that gives a wrong answer you can simply ignore, an agent that makes a wrong booking or sends the wrong email has already caused a real-world consequence.</p>
<p>There is also the issue of security. Agents that can read emails, access files, and browse the web are attractive targets. A technique called "<a href="https://owasp.org/www-community/attacks/PromptInjection">prompt injection</a>" can trick an agent into following malicious instructions hidden inside a webpage or document it reads during a task.</p>
<p>Finally, there is accountability. When an AI agent makes a mistake across a ten-step workflow, it can be genuinely hard to trace exactly where things went wrong, and who or what is responsible.</p>
<p>This is why most well-designed agentic systems today include a "human in the loop": a checkpoint where a person reviews and approves key decisions before the agent acts on them.</p>
<h2 id="heading-what-this-means-for-you">What This Means for You</h2>
<p>You do not need to be a developer to feel the impact of agentic AI. These systems are already being built into the tools people use every day: email clients, project management apps, CRM systems, and more.</p>
<p>The shift worth understanding is this: AI is moving from a tool you interact with to a system that works alongside you. The chatbot answered your questions. The agent handles your tasks.</p>
<p>That is a meaningful change: not just in how AI works, but in how we work with it. The more you understand what agents can and cannot do, the better placed you are to use them well, delegate wisely, and catch mistakes before they snowball.</p>
<p>Agentic AI is not science fiction. It is already in your workplace, and it is only going to become more capable from here.</p>
<p>Understanding the technology is the first step. The next is deciding how to put it to work.</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[ How to Create a Marketing Landing Page Using shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most marketing landing pages start with the same problem: you're staring at a blank screen and rebuilding sections you've already created countless times. A hero section, feature grid, testimonials, p ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-create-a-marketing-landing-page-using-shadcn-ui/</link>
                <guid isPermaLink="false">6a6c8095409fd0bcb0afd1c6</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Fri, 31 Jul 2026 11:01:41 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/82bb4c21-aebe-48c4-9a66-f013011223f4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most marketing landing pages start with the same problem: you're staring at a blank screen and rebuilding sections you've already created countless times.</p>
<p>A hero section, feature grid, testimonials, pricing, FAQ, and footer are common building blocks, yet developers often spend hours recreating them for every new project.</p>
<p>In this guide, you'll learn how to build a modern marketing landing page using Next.js, shadcn/ui, Tailwind CSS, and reusable Shadcn blocks. Instead of building every section from scratch, you'll assemble a production-ready page, customize it to match your brand, and finish with a foundation that's ready for real-world projects.</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-are-we-building">What Are We Building?</a></p>
</li>
<li><p><a href="#heading-project-setup-with-base-ui-using-the-shadcn-preset">Project Setup with Base UI Using the Shadcn Preset</a></p>
</li>
<li><p><a href="#heading-two-ways-to-build-your-marketing-landing-page">Two Ways to Build Your Marketing Landing Page</a></p>
</li>
<li><p><a href="#heading-option-1-build-using-the-cli">Option 1: Build Using the CLI</a></p>
</li>
<li><p><a href="#heading-option-2-build-using-the-mcp-server">Option 2: Build Using the MCP Server</a></p>
</li>
<li><p><a href="#heading-how-to-optimize-your-landing-page">How to Optimize Your Landing Page</a></p>
</li>
<li><p><a href="#heading-how-to-expand-your-marketing-website">How to Expand Your Marketing Website</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview:</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>Node.js 18 or higher installed</p>
</li>
<li><p>shadcn/ui initialized in your project (<code>npx shadcn@latest init</code>)</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
</ul>
<p>If you haven't initialized shadcn/ui yet, run <code>npx shadcn@latest init</code> in your project root and follow the prompts before continuing.</p>
<h2 id="heading-what-are-we-building"><strong>What Are We Building?</strong></h2>
<p>In this tutorial, we'll build a modern marketing landing page using production-ready blocks from <a href="https://shadcnspace.com/">Shadcn Space</a>. Instead of designing and developing every section from scratch, we'll assemble a complete landing page using reusable shadcn/ui blocks and customize them to fit our brand and product.</p>
<p>You can use the same approach to create landing pages for SaaS products, AI tools, startups, agencies, developer tools, portfolios, and many other types of websites. Since every block is built with React, Tailwind CSS, and shadcn/ui, you have full control over the code and can easily modify the content, layout, and styling.</p>
<h3 id="heading-why-build-with-shadcn-space">Why Build with Shadcn Space?</h3>
<p>Creating a professional marketing website typically involves designing multiple sections that work together to tell your product's story and guide visitors to take action.</p>
<p>But instead of building every section from scratch, you can start with production-ready blocks that are easy to customize.</p>
<p>This lets you build marketing websites faster with reusable blocks (and you can also mix and match blocks to create unique page layouts). It also gives you full ownership of your clean React and Tailwind CSS code. And overall, you save development time without sacrificing flexibility.</p>
<h3 id="heading-sections-well-build">Sections We'll Build</h3>
<p>We'll build our marketing landing page using the following sections:</p>
<ul>
<li><p>Hero section with a compelling headline, call-to-action, and trusted-by logos.</p>
</li>
<li><p>Features section to highlight your product's key capabilities.</p>
</li>
<li><p>Product Showcase &amp; Benefits section to demonstrate your product and communicate its value.</p>
</li>
<li><p>Testimonials section to build credibility with customer feedback.</p>
</li>
<li><p>Pricing section to present your plans clearly.</p>
</li>
<li><p>FAQ section answers common questions and reduces friction.</p>
</li>
<li><p>Call-to-Action section to encourage visitors to get started.</p>
</li>
<li><p>Footer with navigation and important links.</p>
</li>
</ul>
<h3 id="heading-final-page-structure">Final Page Structure</h3>
<p>Our landing page will have this structure:</p>
<pre><code class="language-javascript">&lt;main&gt;
  {/* 1. Hero section + Trusted by / Logo cloud */}
  &lt;AgencyHeroSection /&gt;

  {/* 2. Features section */}
  &lt;Feature01 /&gt;

  {/* 3. Product showcase &amp; Benefits */}
  &lt;AboutAndStats01 /&gt;

  {/* 4. Testimonials */}
  &lt;Testimonials /&gt;

  {/* 5. Pricing section */}
  &lt;Pricing /&gt;

  {/* 6. FAQ section */}
  &lt;Faq /&gt;

  {/* 7. Call-to-action section */}
  &lt;CTA /&gt;

  {/* Footer */}
  &lt;Footer /&gt;
&lt;/main&gt;
</code></pre>
<p>Each section will be installed from the Shadcn Space registry and customized directly inside our project. By the end of this tutorial, you'll have a fully responsive marketing landing page built with Next.js, Tailwind CSS, and shadcn/ui that you can adapt for your own product or business.</p>
<h2 id="heading-project-setup-with-base-ui-using-the-shadcn-preset"><strong>Project Setup with Base UI Using the Shadcn Preset</strong></h2>
<p>Since Shadcn Space blocks are built using Base UI primitives, we'll create our project using the Base UI preset instead of the default Radix setup.</p>
<p>This ensures that our landing page uses the same foundation as the blocks we're going to install.</p>
<h3 id="heading-1-create-the-project-with-base-ui">1. Create the Project with Base UI</h3>
<p>Run the following command:</p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest init --preset b0 --template next
</code></pre>
<p>This command does a few important things:</p>
<ul>
<li><p>Creates a Next.js project</p>
</li>
<li><p>Configures Tailwind CSS</p>
</li>
<li><p>Sets up Base UI as the component foundation</p>
</li>
<li><p>Uses the Nova style preset</p>
</li>
<li><p>Configures Lucide icons</p>
</li>
<li><p>Uses Inter font</p>
</li>
<li><p>Applies neutral theme tokens</p>
</li>
</ul>
<p>You now have a Base UI-powered Next.js project ready for building your landing page.</p>
<h3 id="heading-2-add-the-shadcn-space-registry">2. Add the Shadcn Space Registry</h3>
<p>Open your <code>components.json</code> and add the following registry configuration:</p>
<pre><code class="language-javascript">{
  "registries": {
    "@shadcn-space": {
      "url": "https://shadcnspace.com/r/{name}.json",
    }
  }
}
</code></pre>
<p>This tells the CLI where to fetch components and blocks from the registry.</p>
<p>For more information about how to use it in your project, <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli">check out the docs</a>.</p>
<h2 id="heading-two-ways-to-build-your-marketing-landing-page"><strong>Two Ways to Build Your Marketing Landing Page</strong></h2>
<p>Now that your project is set up with shadcn/ui, it's time to start building the marketing landing page.</p>
<p>You can install production-ready blocks directly into your project in two different ways:</p>
<ul>
<li><p>Using the CLI</p>
</li>
<li><p>Using the MCP Server inside your AI-powered editor</p>
</li>
</ul>
<p>Both approaches install the actual React and Tailwind CSS source code into your project, giving you complete control over customization. The only difference is how you discover and install the blocks.</p>
<h2 id="heading-option-1-build-using-the-cli">Option 1: Build Using the CLI</h2>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/n6dvjVxy02U" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>The CLI is the fastest way to browse the registry and install individual blocks into your project. It gives you full control over which sections you want to use and how you customize them.</p>
<h3 id="heading-step-1-browse-marketing-blocks">Step 1: Browse Marketing Blocks</h3>
<p>Visit the block registry and explore the available marketing blocks.</p>
<p>Let's review the sections we'll use for this tutorial:</p>
<ul>
<li><p>Hero section with a call-to-action and trusted-by logos</p>
</li>
<li><p>Features section</p>
</li>
<li><p>Product showcase &amp; benefits section</p>
</li>
<li><p>Testimonials section</p>
</li>
<li><p>Pricing section</p>
</li>
<li><p>FAQ section</p>
</li>
<li><p>Call-to-action section</p>
</li>
<li><p>Footer</p>
</li>
</ul>
<p>Choose the blocks that best match the design and style of your website. Since every block is fully customizable, you can easily update the content, colors, spacing, and layout to match your brand.</p>
<p>In the following sections, we'll install each block and customize them.</p>
<h3 id="heading-step-2-install-selected-blocks">Step 2: Install Selected Blocks</h3>
<p>Once you find a block you like, install it using the CLI:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/{block-name}
</code></pre>
<p>Each command downloads the block, places it inside <code>components/shadcn-space/blocks</code>, and installs the required dependencies.</p>
<p>Now your folder might look like this:</p>
<pre><code class="language-javascript">components/
  shadcn-space/
    blocks/
      about-us-section-01/
      hero-01/
      features-01/
      pricing-01/
      testimonial-01/
      faq-01/
      cta-01/
      footer-01/
</code></pre>
<p><strong>Note:</strong> I've used the first block from each section in this tutorial. You can choose any other <a href="https://shadcnspace.com/blocks"><strong>shadcn block</strong></a> that suits best according to your needs.</p>
<h3 id="heading-step-3-add-a-hero-section">Step 3: Add a Hero Section</h3>
<p>Every great marketing landing page starts with a strong hero section. It's the first thing visitors see, so it should clearly communicate what your product does, who it's for, and encourage users to take action.</p>
<p>Instead of building the section from scratch, we'll install a production-ready Hero block and customize it to match our landing page.</p>
<h4 id="heading-1-install-the-hero-block">1. Install the Hero Block</h4>
<p>Run the following CLI command to add the Hero block to your project:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/hero-01
</code></pre>
<p>The CLI will automatically download the Hero block source code, add the component to your project, and install any required dependencies.</p>
<p>After the installation completes, you should see the following directory structure:</p>
<pre><code class="language-javascript">components/
  shadcn-space/
    blocks/
      hero-01/
        index.tsx
</code></pre>
<p>You can now open the component and customize the heading, description, call-to-action buttons, images, and other content to match your product and branding.</p>
<p><strong>2. Understand the Hero Block Structure</strong></p>
<p>Once the installation is complete, open the Hero block located at:</p>
<pre><code class="language-javascript">components/shadcn-space/blocks/hero-01/index.tsx
</code></pre>
<p>You'll see a component similar to the following:</p>
<pre><code class="language-javascript">import HeroSection from "@/components/shadcn-space/blocks/hero-01/hero";
import type { NavigationSection } from "@/components/shadcn-space/blocks/hero-01/header";
import Header from "@/components/shadcn-space/blocks/hero-01/header";
import BrandSlider, { BrandList } from "@/components/shadcn-space/blocks/hero-01/brand-slider";
import type { AvatarList } from "@/components/shadcn-space/blocks/hero-01/hero";

export default function AgencyHeroSection() {
  const avatarList: AvatarList[] = [...];
  const navigationData: NavigationSection[] = [...];
  const brandList: BrandList[] = [...];

  return (
    &lt;div className="relative"&gt;
      &lt;Header navigationData={navigationData} /&gt;
      &lt;main&gt;
        &lt;HeroSection avatarList={avatarList} /&gt;
        &lt;BrandSlider brandList={brandList} /&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p><strong>What Should You Notice?</strong></p>
<p>Before making any changes, take a moment to understand how the block is organized. Rather than being a single large component, it's composed of smaller, reusable components that work together.</p>
<p>In this example:</p>
<ul>
<li><p>The <code>Header</code> component renders the navigation and receives its menu items through the <code>navigationData</code> array.</p>
</li>
<li><p>The <code>HeroSection</code> component contains the main headline, description, call-to-action buttons, and social proof, while the <code>avatarList</code> provides the data displayed in the hero.</p>
</li>
<li><p>The <code>BrandSlider</code> component displays the company logos using the <code>brandList</code> array.</p>
</li>
</ul>
<p>This separation keeps the code modular and makes each part of the landing page easier to customize or replace independently.</p>
<p><strong>Why This Matters?</strong></p>
<p>Because the block is copied directly into your project, you're working with standard React components instead of a compiled package. Every file is fully editable, allowing you to understand how the section is built and modify it to suit your own requirements.</p>
<p>For example, you can update the navigation links, replace the placeholder content with your own branding, add additional sections, integrate custom functionality, or adjust the styling using Tailwind CSS classes. Since everything lives inside your codebase, you're free to restructure the component however you like without being locked into predefined APIs or abstractions.</p>
<h4 id="heading-3-render-the-hero-section">3. Render the Hero Section</h4>
<p>Now that the Hero block has been installed, it's time to display it on the page.</p>
<p>Import the component into your <code>app/page.tsx</code> file:</p>
<pre><code class="language-javascript">import AgencyHeroSection from "@/components/shadcn-space/blocks/hero-01";

export default function Page() {
  return (
    &lt;AgencyHeroSection /&gt;
  );
}
</code></pre>
<p>Save the file and start your development server if it isn't already running. When you open the application in your browser, you'll see the Hero section rendered as the first part of your marketing landing page.</p>
<p>With the Hero section in place, we've completed the first building block of our landing page. Next, we'll continue by adding the remaining sections to create a complete marketing website.</p>
<h3 id="heading-install-the-remaining-blocks">Install the Remaining Blocks</h3>
<p>Now that you've added and rendered the Hero section, let's install the remaining blocks required for our marketing landing page.</p>
<p>Run the following command to install all the remaining sections at once:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/feature-01 @shadcn-space/about-us-section-01 @shadcn-space/testimonial-01 @shadcn-space/pricing-01 @shadcn-space/faq-01 @shadcn-space/cta-01  @shadcn-space/footer-01
</code></pre>
<p><strong>Note:</strong> If you're using Windows Command Prompt or PowerShell, run the command on a single line instead of using <code>\</code> for line continuation.</p>
<p>After the installation is complete, update your <code>app/page.tsx</code> by importing the newly added blocks and rendering them in the following order:</p>
<pre><code class="language-javascript">import AgencyHeroSection from "@/components/shadcn-space/blocks/hero-01";
import AboutAndStats01 from "@/components/shadcn-space/blocks/about-us-01";
import Feature01 from "@/components/shadcn-space/blocks/feature-01";
import Pricing from "@/components/shadcn-space/blocks/pricing-01/pricing";
import Testimonials from "@/components/shadcn-space/blocks/testimonial-01/testimonial";
import Faq from "@/components/shadcn-space/blocks/faq-01/faq";
import CTA from "@/components/shadcn-space/blocks/cta-01/cta";
import Footer from "@/components/shadcn-space/blocks/footer-01/footer";


export const metadata = {
  title: "Acme Agency – Innovative Digital Solutions",
  description:
    "We craft immersive digital experiences for bold brands. Explore our services, pricing, and success stories.",
};


export default function Page() {
  return (
    &lt;main&gt;
      {/* 1. Hero section + Trusted by / logo cloud */}
      &lt;AgencyHeroSection /&gt;


      {/* 2. Features section */}
      &lt;Feature01 /&gt;


      {/* 3. Product showcase &amp; Benefits section (Using About/Stats as a placeholder for these) */}
      &lt;AboutAndStats01 /&gt;


      {/* 4. Testimonials */}
      &lt;Testimonials /&gt;


      {/* 5. Pricing section */}
      &lt;Pricing /&gt;


      {/* 6. FAQ section */}
      &lt;Faq /&gt;


      {/* 7. Call-to-action section */}
      &lt;CTA /&gt;


      {/* Footer */}
      &lt;Footer /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>That's it! Your complete marketing landing page is now assembled. You can start customizing the content, images, colors, and layout of each section to match your product and brand.</p>
<h3 id="heading-customize-the-marketing-landing-page"><strong>Customize the Marketing Landing Page</strong></h3>
<p>At this point, the overall structure of your marketing landing page is complete. The next step is to personalize each section so it reflects your product, brand, and messaging instead of the default placeholder content.</p>
<p>One of the biggest advantages of working with reusable React components is that every section can be customized independently. You can update the content, replace images, adjust layouts, and refine the styling without rebuilding the page from scratch.</p>
<p>The following examples show how the default blocks can be transformed into a polished marketing website.</p>
<p><strong>Customize the Hero Section</strong></p>
<p>The Hero section is the first thing visitors see, so it's the most important place to communicate your product's value. Replace the placeholder headline, supporting text, call-to-action buttons, and trusted brand logos with content that represents your own business.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/f29da0e0-0da0-482e-bd29-35f3c3c696db.png" alt="Customize the Hero Section Before Using MCP" style="display:block;margin:0 auto" width="1919" height="850" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/1ec0dad0-0dee-448f-aed2-136e310bbc5b.png" alt="Customize the Hero Section After Using MCP" style="display:block;margin:0 auto" width="1906" height="865" loading="lazy">

<p>Notice how the customized version immediately establishes the product's identity through updated messaging, branding, imagery, and call-to-action buttons.</p>
<p><strong>Customize the Features Section</strong></p>
<p>The Features section should explain what your product offers and why it stands out. Replace the sample feature cards with capabilities that highlight your product's most valuable functionality.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/b44dfc47-8603-4a1c-847e-b608b84c7723.png" alt="Customize the Features Section Before Using MCP" style="display:block;margin:0 auto" width="1474" height="866" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/dbc1d529-88ac-42f2-a5d1-0c0348165944.png" alt="Customize the Features Section After Using MCP" style="display:block;margin:0 auto" width="1525" height="936" loading="lazy">

<p>Updating the feature titles, descriptions, and icons makes the section more relevant to your audience while reinforcing your product's key selling points.</p>
<p><strong>Customize the Pricing Section</strong></p>
<p>Your pricing section should clearly communicate the plans you offer and help visitors choose the option that best fits their needs. Replace the default plans, pricing, feature lists, and button labels with information that matches your business model.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/7e053524-b281-4ee4-9afe-f0ee1ad76d48.png" alt="Customize the Pricing Section Before Using MCP" style="display:block;margin:0 auto" width="1631" height="722" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/5ae201a8-46a9-4ca4-909c-7e1ecc5c4077.png" alt="Customize the Pricing Section After Using MCP" style="display:block;margin:0 auto" width="1631" height="817" loading="lazy">

<p>A customized pricing section builds trust by presenting accurate information while making it easier for potential customers to compare plans.</p>
<p><strong>Customize the FAQ Section</strong></p>
<p>The FAQ section is a great opportunity to answer common questions before visitors contact your team. Replace the placeholder questions with answers related to your product, pricing, integrations, support, or onboarding process.</p>
<p><strong>Before:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/4b161671-61f0-45c0-8266-3f39015958ec.png" alt="Customize the FAQ Section Before Using MCP" style="display:block;margin:0 auto" width="1650" height="883" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/8f68f5a8-ad09-4021-843a-58329d0a08ce.png" alt="Customize the FAQ Section After Using MCP" style="display:block;margin:0 auto" width="1638" height="785" loading="lazy">

<p>Tailoring the FAQ to your product helps reduce uncertainty, improves the user experience, and can answer many questions before a customer reaches out.</p>
<p><strong>The Result</strong></p>
<p>With just a few content updates, the default blocks evolve into a professional marketing landing page tailored to your brand. Since every section is built with reusable React components, you can continue refining the design, adjusting layouts, and adding new content as your product grows without changing the overall page structure.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/3759ae7a-d244-487d-8354-d9dd42570bfd.gif" alt="Full Preview of the Landing Page" style="display:block;margin:0 auto" width="1909" height="840" loading="lazy">

<h2 id="heading-option-2-build-using-the-mcp-server"><strong>Option 2: Build Using the MCP Server</strong></h2>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/mMlxAmJlbMI" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>If you prefer a faster workflow, you can use the MCP Server to generate your landing page directly inside your editor. Instead of manually browsing and installing individual blocks, you simply describe the page you want to build, and the MCP Server assembles an initial version for you.</p>
<p>The MCP Server works with supported editors like Antigravity, VS Code, Cursor, Windsurf, and other MCP-compatible editors.</p>
<h3 id="heading-step-1-install-the-mcp-server">Step 1: Install the MCP Server</h3>
<p><strong>Quick Installation</strong>:</p>
<p>The fastest way to get started. Choose your package manager and run the command corresponding to your client:</p>
<p><strong>For Claude Code</strong>:</p>
<pre><code class="language-javascript">claude mcp add shadcnspace-mcp -- npx -y shadcnspace-mcp@latest
</code></pre>
<p><strong>For Others</strong>:</p>
<pre><code class="language-javascript">npx shadcnspace-cli install &lt;client&gt;
</code></pre>
<p>Replace <code>&lt;client&gt;</code> with <strong>cursor, antigravity, vscode,</strong> or <strong>windsurf</strong>.</p>
<p><strong>Manual Installation For VS Code:</strong></p>
<pre><code class="language-javascript">{
  "servers": {
    "shadcnspace-mcp": {
      "command": "npx",
      "args": ["-y", "shadcnspace-mcp@latest"]
    }
  }
}
</code></pre>
<ul>
<li><p>Open .vscode/mcp.json.</p>
</li>
<li><p>Click Start next to the Shadcn Space MCP server</p>
</li>
</ul>
<p>For a detailed guide, follow the <a href="https://shadcnspace.com/docs/getting-started/mcp-server-docs"><strong>MCP Server documentation</strong></a> to install it for your preferred editor.</p>
<p>Once the installation is complete, restart your editor to enable the MCP connection.</p>
<h3 id="heading-step-2-open-the-ai-chat">Step 2: Open the AI Chat</h3>
<p>Open the AI chat panel inside your editor and describe the landing page you'd like to create.</p>
<p>For example:</p>
<pre><code class="language-javascript">Create a modern marketing landing page for an AI SaaS product.

Use Shadcn Space blocks and include:

- Hero section
- Features section
- Product showcase &amp; benefits
- Testimonials
- Pricing
- FAQ
- Call-to-action
- Footer

Use a clean, modern, and responsive design.
</code></pre>
<p>Feel free to replace the product description with your own and customize as you like.</p>
<h3 id="heading-step-3-generate-the-landing-page">Step 3: Generate the Landing Page</h3>
<p>After receiving your prompt, the MCP Server analyzes your requirements and selects the most suitable blocks for your landing page. It automatically assembles the page using a combination of reusable sections, giving you a working layout in just a few moments.</p>
<p>The generated sections are added directly to your project as standard React components. Since everything is real source code, you can customize every part of the page: update the content, replace images, modify the layout, adjust spacing, or restyle components using Tailwind CSS.</p>
<p>The MCP Server simply speeds up the initial setup, while giving you complete control over the final implementation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/2247726d-12ab-47d8-bd7a-49b025ea0eb5.gif" alt="How to Generate the Landing Page" style="display:block;margin:0 auto" width="1909" height="913" loading="lazy">

<h2 id="heading-how-to-optimize-your-landing-page">How to Optimize Your Landing Page</h2>
<p>Building a visually appealing landing page is only the first step. Before publishing your website, it's worth spending some time optimizing it for performance, search engines, and user experience.</p>
<p>A fast, responsive landing page not only feels more polished but also helps improve engagement and conversion rates. Fortunately, Next.js provides several built-in features that make these optimizations straightforward.</p>
<h3 id="heading-optimize-images-with-nextjs">Optimize Images with Next.js</h3>
<p>Images are often the largest assets on a marketing website, so optimizing them can significantly improve loading performance. If you're using Next.js, prefer the built-in <code>next/image</code> component instead of the standard HTML image tag. It automatically serves appropriately sized images, supports modern image formats, and reduces layout shifts as the page loads.</p>
<p>Before adding screenshots or illustrations to your project, make sure they're compressed and sized appropriately. Well-optimized images create a smoother browsing experience across desktop and mobile devices while also contributing to better Core Web Vitals.</p>
<h3 id="heading-improve-seo-for-your-landing-page">Improve SEO for Your Landing Page</h3>
<p>A well-designed landing page is only effective if people can discover it. Search engine optimization starts with creating meaningful content that clearly communicates what your product offers. Choose a descriptive page title, write a concise meta description, and organize your content using logical headings.</p>
<p>Your primary keyword should appear naturally throughout the page without forcing it into every paragraph. Focus on writing for your audience first, then structure the content in a way that search engines can easily understand. Combining valuable content with a clear page hierarchy gives your landing page the best chance of ranking for relevant searches.</p>
<h3 id="heading-add-metadata-and-open-graph-images">Add Metadata and Open Graph Images</h3>
<p>When someone shares your landing page on social media or in a messaging application, the preview is generated from your page's metadata. Configuring Open Graph and Twitter metadata allows you to control the title, description, and preview image that appear when your website is shared.</p>
<p>A custom preview image that reflects your branding makes your links look more professional and can encourage more people to click through. Taking a few minutes to configure these settings helps create a more polished experience whenever your content is shared online.</p>
<h3 id="heading-optimize-performance">Optimize Performance</h3>
<p>Performance plays a major role in how visitors perceive your website. A page that loads quickly feels more responsive and encourages users to continue exploring your content. As you customize your landing page, keep unnecessary JavaScript to a minimum, optimize static assets, and avoid loading resources that aren't immediately needed.</p>
<p>Even small improvements, such as reducing image sizes or simplifying animations, can noticeably improve loading speed. Before deploying your project, test the page under different network conditions to ensure it performs well for all visitors.</p>
<h3 id="heading-improve-core-web-vitals">Improve Core Web Vitals</h3>
<p>Core Web Vitals are Google's metrics for measuring real-world user experience. They evaluate how quickly your main content appears, how responsive the page feels during interactions, and whether elements remain stable as the page loads. Monitoring these metrics throughout development helps identify potential issues before they affect users.</p>
<p>Tools such as Lighthouse and PageSpeed Insights provide detailed reports that can help you improve loading performance and responsiveness. A landing page with strong Core Web Vitals not only creates a better experience for visitors but can also contribute to improved search rankings.</p>
<hr>
<h2 id="heading-how-to-expand-your-marketing-website"><strong>How to Expand Your Marketing Website</strong></h2>
<p>A landing page is often just the beginning of a complete marketing website. As your product grows, you'll likely need additional pages that provide more information, improve navigation, and create a better experience for your visitors. Common additions include Blog, Blog Details, Pricing, FAQ, Changelog, Contact, Integration, Error, and About Us pages.</p>
<p>Building these pages with a consistent design system helps maintain a unified look and feel across your entire website while reducing development time. Reusing layouts and components also makes your project easier to maintain as it evolves.</p>
<p>If you're looking to expand your website beyond a single landing page, explore the collection of production-ready <a href="https://shadcnspace.com/pages"><strong>Shadcn website pages</strong></a>, built with React, Next.js, Tailwind CSS, and shadcn/ui.</p>
<h2 id="heading-live-preview"><strong>Live Preview:</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/a189f09d-550d-4c7f-b20a-477f5bc1bfa0.gif" alt="How to Expand Your Marketing Website" style="display:block;margin:0 auto" width="1909" height="913" loading="lazy">

<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this tutorial, we built a complete marketing landing page using Next.js, shadcn/ui, Tailwind CSS, and reusable Shadcn Space blocks. Starting from a fresh project, we assembled a production-ready page by combining sections such as the Hero, Features, Product Showcase, Testimonials, Pricing, FAQ, Call-to-Action, and Footer.</p>
<p>Because every block is added as standard React source code, you're free to customize the content, layout, styling, and functionality to match your own product and branding. Whether you're building a SaaS application, a startup website, an AI product, an agency site, or a developer tool, the same approach can be adapted to your requirements.</p>
<p>A marketing landing page is just the foundation of your online presence. As your product grows, you can continue expanding your website with additional marketing pages while maintaining a consistent design system and development workflow.</p>
<p>I hope this guide has helped you understand how to quickly build a modern, responsive marketing landing page using reusable components. Feel free to experiment with different block combinations, personalize the design, and create a website that best represents your product.</p>
<p><strong>Appreciation</strong>: I wrote this article with the help of Mihir Koshti (Sr. Full Stack Developer) – <a href="https://www.linkedin.com/in/mihir-koshti/">Connect on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Agentic AI using LangGraph – Build AI Agents & Automate Workflows ]]>
                </title>
                <description>
                    <![CDATA[ We are rapidly moving past standard, single-prompt Large Language Models and entering the era of autonomous AI agents. To help you master this new paradigm, we have just published a massive, comprehen ]]>
                </description>
                <link>https://www.freecodecamp.org/news/agentic-ai-using-langgraph-build-ai-agents-automate-workflows/</link>
                <guid isPermaLink="false">6a6b625e08d602aac2992fab</guid>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Thu, 30 Jul 2026 14:40:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/d09f5182-8b2e-4cb8-b376-7b61658769fe.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>We are rapidly moving past standard, single-prompt Large Language Models and entering the era of autonomous AI agents.</p>
<p>To help you master this new paradigm, we have just published a massive, comprehensive course on the freeCodeCamp.org YouTube channel that will teach you all about agentic AI.</p>
<p>This course is designed to take you from the foundational concepts of agentic AI all the way through to building production-ready, end-to-end agent workflows using LangChain and LangGraph.</p>
<p>Here are a few key takeaways you can expect from the course:</p>
<ul>
<li><p>The architectural differences between standard LLMs and Agentic AI.</p>
</li>
<li><p>How to build both single and multi-agent systems using LangChain.</p>
</li>
<li><p>The core components of LangGraph and why it is essential for stateful, cyclical agent workflows.</p>
</li>
<li><p>Advanced techniques including Human-in-the-Loop (HITL), Retrieval-Augmented Generation (RAG), and streaming responses.</p>
</li>
<li><p>Best practices for deploying agentic systems to production environments like AWS and Render using Docker and GitHub Actions.</p>
</li>
</ul>
<p>This is a hands-on, project-driven course. By the end of the 24 hours, you will have built fully functional, deployable AI agents from scratch.</p>
<p>Head over to the freeCodeCamp.org <a href="https://www.youtube.com/watch?v=Zy7EXDONlTY">YouTube channel to watch the full course</a> (24-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/Zy7EXDONlTY" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Kubernetes Operators: A Handbook for Devs ]]>
                </title>
                <description>
                    <![CDATA[ Kubernetes ships with controllers that manage a fixed set of built-in resources: Deployments, Services, Nodes, and so on. An operator extends the same pattern to resources Kubernetes doesn't know abou ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-kubernetes-operators-a-handbook-for-devs/</link>
                <guid isPermaLink="false">6a6a09f608b0619a2131a1cb</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Go Language ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Karan Pratap Singh ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 14:11:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/21c81312-eb74-40f3-823a-3831945a3f58.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Kubernetes ships with controllers that manage a fixed set of built-in resources: Deployments, Services, Nodes, and so on.</p>
<p>An operator extends the same pattern to resources Kubernetes doesn't know about natively, letting you manage custom, often external, systems the same declarative way you manage everything else in the cluster.</p>
<p>This guide is divided into four parts: what an operator actually is, the anatomy of one, building one from scratch, and preparing it for production.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-part-1-introduction">Part 1: Introduction</a></p>
<ul>
<li><p><a href="#heading-what-is-an-operator">What is an Operator?</a></p>
</li>
<li><p><a href="#heading-operator-vs-controller-vs-crd">Operator vs Controller vs CRD</a></p>
</li>
<li><p><a href="#heading-why-not-just-a-helm-chart-a-cronjob-or-a-script">Why Not Just a Helm Chart, a CronJob, or a Script?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-anatomy-of-an-operator">Part 2: Anatomy of an Operator</a></p>
<ul>
<li><p><a href="#heading-custom-resource">Custom Resource</a></p>
</li>
<li><p><a href="#heading-watching-for-change">Watching for Change</a></p>
</li>
<li><p><a href="#heading-manager">Manager</a></p>
</li>
<li><p><a href="#heading-reconciliation-loop">Reconciliation Loop</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-3-building-the-operator">Part 3: Building the Operator</a></p>
<ul>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
<li><p><a href="#heading-mock-provider">Mock Provider</a></p>
</li>
<li><p><a href="#heading-defining-the-virtualmachine-crd">Defining the VirtualMachine CRD</a></p>
</li>
<li><p><a href="#heading-reconciler">Reconciler</a></p>
</li>
<li><p><a href="#heading-failure-handling-amp-retries">Failure Handling &amp; Retries</a></p>
</li>
<li><p><a href="#heading-finalizer">Finalizer</a></p>
</li>
<li><p><a href="#heading-predicate">Predicate</a></p>
</li>
<li><p><a href="#heading-owned-resources">Owned Resources</a></p>
</li>
<li><p><a href="#heading-cross-resource-reconciliation">Cross-Resource Reconciliation</a></p>
</li>
<li><p><a href="#heading-rbac">RBAC</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-production-amp-deployment">Part 4: Production &amp; Deployment</a></p>
<ul>
<li><p><a href="#heading-packaging-amp-deployment">Packaging &amp; Deployment</a></p>
</li>
<li><p><a href="#heading-performance-amp-resilience">Performance &amp; Resilience</a></p>
</li>
<li><p><a href="#heading-security">Security</a></p>
</li>
<li><p><a href="#heading-observability">Observability</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-next-steps">Next steps</a></p>
</li>
</ul>
<h2 id="heading-part-1-introduction">Part 1: Introduction</h2>
<h3 id="heading-what-is-an-operator">What is an Operator?</h3>
<p>Kubernetes works by comparing the state we describe against actual state. A controller acts to close the gap, whether that's the Deployment controller replacing a pod we killed or scaling down the ones we no longer want.</p>
<p>This loop of observe, compare, and act is called <strong>reconciliation</strong>. It means looking up a resource's desired and actual state, deciding what to do next, and recomputing that decision fresh on every run regardless of what changed.</p>
<p>That's what makes the loop resilient: it never has to trust that it saw every event, only that it gets called again.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/reconcile-loop.png" alt="reconciliation loop illustration" style="display:block;margin:0 auto" width="680" height="400" loading="lazy">

<p>An operator applies that exact same loop to a resource Kubernetes doesn't understand natively. We define a Custom Resource, describe our domain's desired state in it, and write a controller that knows how to reconcile that domain.</p>
<p>That's the whole concept. Everything else in this guide (informers, workqueues, finalizers, status conditions) exists to make that one loop reliable for a resource type Kubernetes only knows about because we defined it.</p>
<h3 id="heading-operator-vs-controller-vs-crd">Operator vs Controller vs CRD</h3>
<p>These three terms are often used interchangeably, but they describe three different layers of the same system.</p>
<ul>
<li><p><strong>CRD (CustomResourceDefinition)</strong>: a schema we register with the Kubernetes API server to teach it about a new resource type. On its own, a CRD does nothing. It only gives the API server a shape to store, validate, and serve.</p>
</li>
<li><p><strong>Controller</strong>: any piece of software running a reconciliation loop against a resource type, from the built-in Deployment controller to a custom controller reconciling a <code>PostgresCluster</code>.</p>
</li>
<li><p><strong>Operator</strong>: a controller, or a small set of controllers, that targets a custom resource and encodes enough domain-specific knowledge to manage its full lifecycle without a human: provisioning, upgrades, failure recovery, and so on.</p>
</li>
</ul>
<p>Every operator is a controller, but not every controller is an operator. A CRD without a controller behind it is just a schema that nothing acts on.</p>
<h3 id="heading-why-not-just-a-helm-chart-a-cronjob-or-a-script">Why Not Just a Helm Chart, a CronJob, or a Script?</h3>
<p>A <a href="https://helm.sh/docs/topics/charts/"><strong>Helm chart</strong></a> renders a set of values into YAML and applies it once. It has no way to keep watching afterward. if a resource it created is deleted or drifts, Helm has no idea until we run <code>helm upgrade</code> again by hand.</p>
<p>A <strong>CronJob</strong> gives us a loop back, at the cost of granularity, staleness up to one interval, no state carried between runs, and no way for one CronJob to react to a status change another one made.</p>
<p>And a <strong>one-off script</strong> only acts when triggered, manually or by a CI pipeline, and does nothing about drift in between runs. It's also rarely written with retries and idempotency as first-class concerns.</p>
<h4 id="heading-why-an-operator-wins-here">Why an operator wins here:</h4>
<p>An operator is event-driven and continuous. The API server notifies it the instant a custom resource is created, updated, or deleted, and it keeps reconciling for that resource's entire lifetime, not just at apply time. That matters most for state that takes time to converge, can fail partway through, and can drift after it's first created.</p>
<p>This comes at a cost, though. An operator is a long-running process with its own RBAC (Role-Based Access Control), failure modes, and observability surface. This is more to build and operate than a chart or a script.</p>
<p>If the problem really is rendering some YAML once, a Helm chart is the right tool. An operator earns its keep when the problem is keeping something continuously correct, which is what the rest of this guide builds toward.</p>
<h2 id="heading-part-2-anatomy-of-an-operator">Part 2: Anatomy of an Operator</h2>
<p>Next, we'll look at the pieces that make that loop actually work: the Custom Resource itself, the machinery that notices when something changed, and the manager that runs it all, before going into the reconciliation loop in detail.</p>
<h3 id="heading-custom-resource">Custom Resource</h3>
<p>Before a controller can reconcile anything, the API server needs to know the shape of what it's storing. Registering a CRD teaches it that shape.</p>
<p>Every Custom Resource carries the same identity fields every Kubernetes object already has (<code>kind</code>, <code>name</code>, <code>namespace</code>, <code>labels</code>, and so on), plus two fields that are entirely ours to define: a spec and a status. That split isn't a style choice. It maps directly to the reconciliation loop.</p>
<ul>
<li><p><strong>Spec</strong> is desired state. Whoever creates or edits the resource writes it, and the controller only ever reads it.</p>
</li>
<li><p><strong>Status</strong> is observed state. It's written only by the controller, to record what it found and what it did.</p>
</li>
</ul>
<p>A client that writes to status directly is working around the controller instead of through it, which is why status is usually served as its own subresource with separate permissions.</p>
<p>The last piece is registration. The API server and any client talking to it need a shared, agreed-upon way to encode and decode our type, so we register it once against a scheme before anything can use it. Without that, our type is just a definition nobody can serve. With it, the API server can store and serve it exactly the way it serves Pods or Deployments.</p>
<p>We'll see exactly what that registration looks like when we build one for real in Part 3.</p>
<h3 id="heading-watching-for-change">Watching for Change</h3>
<p>A reconciler doesn't poll the API server in a loop asking "did anything change yet?" Three pieces work together to avoid that.</p>
<p>An <strong>informer</strong> opens a long-lived watch against the API server and keeps a local, in-memory cache of every object of a given type, updating it as add, update, and delete events arrive.</p>
<p>A <strong>lister</strong> reads from that cache instead of the API server, so a reconciler checking "does this Resource already exist?" costs a local map lookup, not a network call.</p>
<p>A <strong>workqueue</strong> sits between the informer and the reconciler. When the informer sees a change, it doesn't call the reconciler directly. Instead, it enqueues a key, namespace, and name, not the object itself. Workers pull keys off the queue and reconcile them, and the queue deduplicates and rate-limits on our behalf, so ten rapid updates to the same object collapse into one pending item instead of ten redundant reconciles.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/informer-pipeline.png" alt="informer, workqueue, and reconciler pipeline" style="display:block;margin:0 auto" width="880" height="420" loading="lazy">

<p>This is also why a reconciler receives a key and not an object. By the time a worker picks the key off the queue, the object may have changed again, so the reconciler always looks up the current state itself rather than trusting whatever triggered it.</p>
<h3 id="heading-manager">Manager</h3>
<p>The manager is the process that owns all of this: the shared cache the informers populate, the client the reconciler uses to read and write objects, and the health, readiness, and metrics endpoints the rest of the cluster uses to know the controller is alive. Every reconciler we register runs inside one manager.</p>
<p>If we run more than one replica of the same controller for availability, we don't want both replicas reconciling the same object at once and racing each other.</p>
<p>The manager coordinates this through <strong>leader election</strong>. Replicas compete for a lease, exactly one holds it and actively reconciles, and the rest sit idle until the leader stops renewing it.</p>
<p>We'll come back to this in practice in Part 4. For now it's enough to know the manager is what makes it possible.</p>
<h3 id="heading-reconciliation-loop">Reconciliation Loop</h3>
<p>This is the part that matters most. Once we understand this loop well, most of what an operator does is a variation on it.</p>
<p>A reconciler's entry point is called with just a namespace and a name, nothing else. No spec, status, or diff. The reconciler has to fetch the object itself, compare its spec against what it can observe of the actual state, and decide what to do. That constraint is deliberate, and it's the reason for everything below.</p>
<h4 id="heading-idempotency">Idempotency</h4>
<p>Because the reconciler only ever gets a key, and because it can be called any number of times for the same object (in a row, out of order, or after a long gap), it has to produce the same end result no matter how many times it runs. A reconciler that blindly calls create every time it runs breaks the moment it runs twice, since the second call fails against an object that already exists.</p>
<p>The fix is to always check current state before acting: create only if missing, update only if different, and delete only if it shouldn't exist.</p>
<h4 id="heading-event-driven-reconciliation">Event-driven reconciliation</h4>
<p>A reconcile is triggered by a watch event on the resource being reconciled, and by convention also on anything it owns or otherwise depends on. On top of that, most controllers set a periodic resync so the loop also runs on a schedule even with no watch event at all, which matters once state can drift for reasons a watch would never catch.</p>
<h4 id="heading-requeues">Requeues</h4>
<p>Sometimes a single pass through reconcile can't finish the job, becuase the work it's waiting on is still in progress elsewhere. A reconciler can ask to be called again after a delay without treating this as a failure. This is how it polls something that takes time to converge, rather than blocking inside a single call.</p>
<h4 id="heading-error-handling">Error handling</h4>
<p>Returning an error does something similar: it requeues, but with exponential backoff instead of a fixed delay. So a persistently failing reconcile doesn't hammer whatever it's failing against.</p>
<p>It's worth distinguishing errors that are worth retrying (like a timeout or lock conflict) from ones that aren't (like a spec that will never be valid, which should be surfaced as a status condition instead of retried forever).</p>
<h4 id="heading-drift-correction">Drift correction</h4>
<p>Put all of the above together and the loop is self-healing by construction. Because reconcile recomputes the full diff every time rather than reacting to what specifically changed, it doesn't matter whether the drift came from someone running <code>kubectl edit</code>, another controller, or the underlying system the resource represents changing state on its own. The next reconcile, whether triggered by a watch event or a resync, sees the same gap either way and closes it the same way.</p>
<h2 id="heading-part-3-building-the-operator">Part 3: Building the Operator</h2>
<p>Everything so far has been building toward this. We now know what an operator is, how the terms around it relate, and what pieces a reconciliation loop is made of.</p>
<p>Now we'll put all of it to use and build <strong>VMOperator</strong>. It's an operator that manages a <code>VirtualMachine</code> Custom Resource backed by a mock cloud provider, a small HTTP service we'll also write that stands in for a real one.</p>
<pre><code class="language-yaml">apiVersion: compute.example.com/v1
kind: VirtualMachine
spec:
  image: ubuntu-22.04
  cpu: 2
  memory: 4Gi
status:
  phase: Running
  id: vm-123
</code></pre>
<p>Say we want to represent a virtual machine in a Kubernetes-native way, <code>kubectl apply</code> a YAML file, and get a VM – all without touching a cloud console or a separate CLI.</p>
<p>That's the motivation behind VMOperator: something that lives entirely outside Kubernetes becomes just another object the cluster's own tooling (<code>kubectl</code>, RBAC, GitOps pipelines) already knows how to work with.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/vmoperator-architecture.png" alt="VMOperator architecture" style="display:block;margin:0 auto" width="1000" height="520" loading="lazy">

<h3 id="heading-setup">Setup</h3>
<p>We'll need a local cluster, and <a href="https://kind.sigs.k8s.io/">kind</a> is the easiest way to get one:</p>
<pre><code class="language-bash">kind create cluster --name vmoperator
</code></pre>
<p>Beyond that, we'll be using <a href="https://go.dev/doc/install">Go</a> in this part for the operator, <code>kubectl</code> pointed at the new cluster, and Python with Flask for the mock provider below.</p>
<pre><code class="language-bash">pip install flask
</code></pre>
<h3 id="heading-mock-provider">Mock Provider</h3>
<p>Before we write controller code, we need something for it to control. The mock provider is a small HTTP service with three endpoints:</p>
<ul>
<li><p><code>POST /vms</code> to create one</p>
</li>
<li><p><code>GET /vms/{id}</code> to check on it</p>
</li>
<li><p><code>DELETE /vms/{id}</code> to remove it</p>
</li>
</ul>
<p>backed by nothing more than a dict in memory.</p>
<p>Every VM it creates starts in <code>Provisioning</code> and flips to <code>Running</code> a few seconds later on its own, which is enough to force our reconciler to actually poll instead of assuming success.</p>
<p>We're writing this one in Python rather than Go. It has nothing to do with the operator's code, as this is only for mock purposes.</p>
<pre><code class="language-python">import random
import string
import threading
import time

from flask import Flask, jsonify, request

app = Flask(__name__)
vms = {}  # in-memory store, keyed by VM id

def provision(vm):
    time.sleep(5)  # simulate provisioning taking time
    vm["phase"] = "Running"

@app.post("/vms")
def create_vm():
    body = request.get_json()
    vm_id = "vm-" + "".join(random.choices(string.digits, k=6))
    vm = {"id": vm_id, "image": body["image"], "phase": "Provisioning"}
    vms[vm_id] = vm

    threading.Thread(target=provision, args=(vm,), daemon=True).start()  # flips to Running in the background

    return jsonify(vm)

@app.get("/vms/&lt;vm_id&gt;")
def get_vm(vm_id):
    vm = vms.get(vm_id)
    if vm is None:
        return "", 404
    return jsonify(vm)

@app.delete("/vms/&lt;vm_id&gt;")
def delete_vm(vm_id):
    vms.pop(vm_id, None)
    return "", 204

if __name__ == "__main__":
    app.run(port=8080, threaded=True)
</code></pre>
<p>We'll run this as its own process, alongside the cluster, listening on the port the operator will be configured to call. Nothing about it knows Kubernetes exists, which is the point: it's standing in for a real cloud API.</p>
<h3 id="heading-defining-the-virtualmachine-crd">Defining the VirtualMachine CRD</h3>
<p>With something to control, we can define what we're controlling. The <code>VirtualMachine</code> type follows exactly the spec and status split from Part 2:</p>
<pre><code class="language-go">type VirtualMachineSpec struct {
	Image  string `json:"image"`
	CPU    int    `json:"cpu"`
	Memory string `json:"memory"`
}

type VirtualMachineStatus struct {
	ID    string `json:"id,omitempty"`    // provider-assigned id, empty until first provisioned
	Phase string `json:"phase,omitempty"` // mirrors the provider's lifecycle phase
}

type VirtualMachine struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   VirtualMachineSpec   `json:"spec,omitempty"`
	Status VirtualMachineStatus `json:"status,omitempty"`
}

type VirtualMachineList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []VirtualMachine `json:"items"`
}
</code></pre>
<p><code>TypeMeta</code> carries <code>kind</code> and <code>apiVersion</code>, the same two fields on every Kubernetes object, built-in or custom, that say what this thing is.</p>
<p><code>ListMeta</code> is its counterpart for list types, <code>resourceVersion</code> and <code>continue</code> for pagination (instead of <code>name</code>/<code>namespace</code>). This is why <code>VirtualMachineList</code> embeds <code>ListMeta</code> next to its <code>TypeMeta</code> while <code>VirtualMachine</code> itself embeds <code>ObjectMeta</code>.</p>
<p>Every type we register needs to satisfy <code>runtime.Object</code>, which means implementing <code>DeepCopyObject</code>. This is normally generated for us, but since we're doing this by hand, here's what that generated code actually looks like for <code>VirtualMachine</code>. The rest follow the same mechanical pattern:</p>
<pre><code class="language-go">func (in *VirtualMachine) DeepCopyObject() runtime.Object {
	out := VirtualMachine{
		TypeMeta:   in.TypeMeta,
		ObjectMeta: *in.ObjectMeta.DeepCopy(), // ObjectMeta already knows how to copy itself
		Spec:       in.Spec,                   // no pointers or slices in Spec, a plain copy is safe
		Status:     in.Status,
	}
	return &amp;out
}
</code></pre>
<p>And the CRD manifest that teaches the API server about it:</p>
<pre><code class="language-yaml">apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: virtualmachines.compute.example.com
spec:
  group: compute.example.com
  scope: Namespaced
  names:
    kind: VirtualMachine
    listKind: VirtualMachineList
    plural: virtualmachines
    singular: virtualmachine
    shortNames: [vm] # lets us type `kubectl get vm` instead of the full plural
  versions:
    - name: v1
      served: true
      storage: true
      subresources:
        status: {} # splits status into its own subresource, see Part 2
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [image, cpu, memory]
              properties:
                image: { type: string }
                cpu: { type: integer }
                memory: { type: string }
            status:
              type: object
              properties:
                phase: { type: string }
                id: { type: string }
</code></pre>
<p>The <code>subresources.status</code> line matters. It's what makes status a separate subresource with its own update path. This is exactly the boundary we talked about in Part 2 between what a client can write and what only the controller can.</p>
<p>The <code>names</code> block is also what <code>kubectl</code> resolves against, <code>kubectl get virtualmachines</code> works because <code>plural</code> says so. <code>shortNames</code> is why <code>kubectl get vm</code> works too, the same way <code>kubectl get po</code> works for Pods.</p>
<h3 id="heading-reconciler">Reconciler</h3>
<p>The reconciler's job is small on paper, look at a <code>VirtualMachine</code>, and make sure a matching VM exists in the provider and its status reflects reality. We wrap the provider's HTTP API behind a small client so the reconciler itself stays readable:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var vm computev1.VirtualMachine
	if err := r.Get(ctx, req.NamespacedName, &amp;vm); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err) // object was deleted, nothing left to do
	}

	if vm.Status.ID == "" {
		// no VM yet, this is the first time we've seen this object
		created, err := r.Provider.Create(ctx, vm.Spec.Image)
		if err != nil {
			return ctrl.Result{}, err
		}

		vm.Status.ID = created.ID
		vm.Status.Phase = created.Phase
		if err := r.Status().Update(ctx, &amp;vm); err != nil {
			return ctrl.Result{}, err
		}

		return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // check back shortly instead of blocking here
	}

	// VM already exists, poll the provider for whatever it knows right now
	current, err := r.Provider.Get(ctx, vm.Status.ID)
	if err != nil {
		return ctrl.Result{}, err
	}

	vm.Status.Phase = current.Phase
	if err := r.Status().Update(ctx, &amp;vm); err != nil {
		return ctrl.Result{}, err
	}

	if current.Phase != "Running" {
		return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // still provisioning, keep polling
	}

	return ctrl.Result{}, nil
}
</code></pre>
<p>Two things are worth calling out. First, this is only reachable at all because we've registered a watch on <code>VirtualMachine</code>. The API server tells us the moment one is created or edited, which is what triggers the first call.</p>
<p>Second, every branch ends by writing to <code>vm.Status</code>, mapping whatever the provider told us onto the resource. Kubernetes never talks to the provider directly. The only way anyone finds out a VM is running is because our reconciler wrote it into status.</p>
<h3 id="heading-failure-handling-amp-retries">Failure Handling &amp; Retries</h3>
<p>Notice the reconciler above never retries anything itself. When <code>r.Provider.Create</code> or <code>r.Provider.Get</code> fails (like because of a network blip or the mock provider not being up yet), it just returns the error. That's deliberate. Returning an error is how we ask controller-runtime to requeue with exponential backoff on our behalf. This means we don't need to hand-roll a retry loop, and a persistently unreachable provider doesn't get flooded with retries.</p>
<p>The one thing worth being careful about is treating every failure the same way. A timeout talking to the provider is worth retrying. A <code>VirtualMachine</code> whose <code>spec.image</code> the provider will never accept is not. Retrying that forever just produces a busy loop that never succeeds.</p>
<p>We'll leave surfacing that distinction through status conditions to the exercises. The reconciler above only has one failure mode to worry about, since the mock provider never rejects a request outright.</p>
<h3 id="heading-finalizer">Finalizer</h3>
<p>If we delete a <code>VirtualMachine</code> right now, Kubernetes removes the object and we're left with an orphaned VM the provider still thinks is running. A finalizer closes that gap: it's a string on the object that tells Kubernetes "don't actually delete this until I say so."</p>
<pre><code class="language-go">const vmFinalizer = "compute.example.com/vm-cleanup"

func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var vm computev1.VirtualMachine
	if err := r.Get(ctx, req.NamespacedName, &amp;vm); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	if !vm.DeletionTimestamp.IsZero() {
		// being deleted, deprovision through the provider before letting it go
		if controllerutil.ContainsFinalizer(&amp;vm, vmFinalizer) {
			if vm.Status.ID != "" {
				if err := r.Provider.Delete(ctx, vm.Status.ID); err != nil {
					return ctrl.Result{}, err
				}
			}
			controllerutil.RemoveFinalizer(&amp;vm, vmFinalizer) // safe to let the delete proceed now
			return ctrl.Result{}, r.Update(ctx, &amp;vm)
		}
		return ctrl.Result{}, nil
	}

	if !controllerutil.ContainsFinalizer(&amp;vm, vmFinalizer) {
		controllerutil.AddFinalizer(&amp;vm, vmFinalizer) // register before we ever provision anything
		if err := r.Update(ctx, &amp;vm); err != nil {
			return ctrl.Result{}, err
		}
	}

	// ... provisioning logic from before
	return ctrl.Result{}, nil
}
</code></pre>
<p>A <code>kubectl delete</code> on a <code>VirtualMachine</code> with our finalizer present doesn't remove it. Rather, it sets <code>deletionTimestamp</code> and waits.</p>
<p>Our reconciler sees that on the next call, deprovisions the VM through the provider, and only then removes the finalizer. At this point Kubernetes finally deletes the object. If there's no finalizer, there's no guarantee that cleanup ever runs.</p>
<h3 id="heading-predicate">Predicate</h3>
<p>There's a subtle bug already sitting in the reconciler above. Every time it calls <code>r.Status().Update</code>, that write is itself a change to the object. This triggers our own watch, which calls reconcile again.</p>
<p>Left alone, this doesn't spin forever, since we're recomputing the same status until it settles. But it's still wasted work reconciling in response to writes we made ourselves.</p>
<p>A predicate filters those events that actually enqueue a reconcile, before our code ever runs:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // drop status only events
		Complete(r)
}
</code></pre>
<p><code>generation</code> only increments when <code>spec</code> changes. Status updates don't touch it. <code>GenerationChangedPredicate</code> uses that to drop events where nothing but status moved, so our own writes stop retriggering us. Then we're back to reconciling only when something meaningful changed, or when we explicitly ask to be requeued.</p>
<h3 id="heading-owned-resources">Owned Resources</h3>
<p>A <code>VirtualMachine</code> being <code>Running</code> somewhere isn't very useful on its own, so let's make the operator also create a <code>Secret</code> holding the VM's connection details in-cluster:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) reconcileConnectionSecret(ctx context.Context, vm *computev1.VirtualMachine) error {
	secret := &amp;corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      vm.Name + "-connection",
			Namespace: vm.Namespace,
		},
		StringData: map[string]string{"id": vm.Status.ID},
	}

	if err := controllerutil.SetControllerReference(vm, secret, r.Scheme); err != nil {
		return err // ties the Secret's lifecycle to this VirtualMachine
	}

	return r.Patch(ctx, secret, client.Apply, client.ForceOwnership, client.FieldOwner("vmoperator")) // create or update, either way
}
</code></pre>
<p><code>SetControllerReference</code> is what makes this an <strong>owned resource</strong>, it stamps an owner reference onto the <code>Secret</code> pointing back at the <code>VirtualMachine</code>.</p>
<p>Two things fall out of that for free. Deleting the <code>VirtualMachine</code> now cascades, Kubernetes garbage collects the <code>Secret</code> automatically, and there's no finalizer needed since it's an in-cluster object, not an external one.</p>
<p>And if we add <code>Owns(&amp;corev1.Secret{})</code> alongside <code>For(&amp;computev1.VirtualMachine{})</code> in <code>SetupWithManager</code>, an edit or deletion of the <code>Secret</code> itself re-triggers reconciliation of its owning <code>VirtualMachine</code>. So if someone deletes it by hand, we notice and recreate it.</p>
<p>The same pattern, <code>SetControllerReference</code> call, and <code>Owns()</code> registration creates a second owned resource: a <code>Service</code> fronting the VM in-cluster. That's two different resource kinds owned by one <code>VirtualMachine</code>, which is all <strong>multiple owned resources</strong> means in practice. There's nothing more to it than calling the same pattern twice for different types.</p>
<h3 id="heading-cross-resource-reconciliation">Cross-Resource Reconciliation</h3>
<p>Every <code>VirtualMachine</code> so far talks to one hardcoded provider endpoint. Real deployments need that to be configurable, and it's rarely a one-off: fifty <code>VirtualMachine</code>s in the same AWS account share the same endpoint and credentials, and a hundred more might live in Azure instead.</p>
<p>We could put an <code>endpoint</code> field directly on <code>VirtualMachineSpec</code>, but rotating a credential or fixing a typo would then mean editing every <code>VirtualMachine</code> that uses it, one at a time. Pulling that into its own object lets many <code>VirtualMachine</code>s reference it by name instead, so a single edit propagates to all of them.</p>
<p>Now let's add a second, small CRD:</p>
<pre><code class="language-go">type ProviderConfigSpec struct {
	Endpoint string `json:"endpoint"`
}
</code></pre>
<p>And a <code>providerRef</code> field on <code>VirtualMachineSpec</code> pointing at one by name. The interesting part isn't the new type. It's what happens when a <code>ProviderConfig</code> changes.</p>
<p>A <code>VirtualMachine</code> doesn't watch <code>ProviderConfig</code> directly, and there's no owner reference between them, so a plain <code>Owns()</code> won't do it. Instead, we watch the type and map each event onto every <code>VirtualMachine</code> that references it:</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/cross-resource-fanout.png" alt="cross-resource reconciliation fan-out" style="display:block;margin:0 auto" width="820" height="380" loading="lazy">

<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Owns(&amp;corev1.Secret{}).
		Owns(&amp;corev1.Service{}).
		Watches(
			&amp;computev1.ProviderConfig{}, // not owned, so Owns() won't catch its changes
			handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig),
		).
		Complete(r)
}

func (r *VirtualMachineReconciler) findVirtualMachinesForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request {
	var vms computev1.VirtualMachineList
	if err := r.List(ctx, &amp;vms, client.InNamespace(obj.GetNamespace())); err != nil {
		return nil
	}

	var requests []reconcile.Request
	for _, vm := range vms.Items {
		if vm.Spec.ProviderRef == obj.GetName() { // only re-enqueue VMs that actually reference this config
			requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&amp;vm)})
		}
	}
	return requests
}
</code></pre>
<p>This is <strong>cross-resource reconciliation</strong>: one resource's change causing a different resource type entirely to reconcile, connected only by a field value rather than ownership.</p>
<p>It's also where the theme of this whole project comes back around. <code>ProviderConfig</code> is what would hold real credentials and a real endpoint for AWS, Azure, or GCP in a production version of this operator. The mock provider is standing in for exactly that boundary.</p>
<h3 id="heading-rbac">RBAC</h3>
<p>None of the above works without permission to act on it. The manifest just has to list what we actually touch: <code>VirtualMachine</code> and <code>ProviderConfig</code> objects, the <code>VirtualMachine</code> status subresource separately, and the <code>Secret</code>/<code>Service</code> objects we create:</p>
<pre><code class="language-yaml">apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: vmoperator-manager-role
rules:
  - apiGroups: ['compute.example.com']
    resources: ['virtualmachines', 'providerconfigs']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
  - apiGroups: ['compute.example.com']
    resources: ['virtualmachines/status'] # separate rule, it's a separate subresource
    verbs: ['get', 'update', 'patch']
  - apiGroups: ['']
    resources: ['secrets', 'services']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: vmoperator-manager-rolebinding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: vmoperator-manager-role
subjects:
  - kind: ServiceAccount
    name: vmoperator-controller-manager
    namespace: vmoperator-system
</code></pre>
<p><strong>Note:</strong> VMOperator manages a resource entirely outside the cluster through a hand-rolled HTTP client, but it's not a novel pattern. <a href="https://www.crossplane.io/">Crossplane</a>, <a href="https://aws-controllers-k8s.github.io/community/">AWS Controllers for Kubernetes</a>, <a href="https://cluster-api.sigs.k8s.io/">Cluster API</a>, and cert-manager all reconcile external or non-Kubernetes state through CRDs the same way. These resources are worth reading once this pattern feels familiar.</p>
<p><strong>Another note:</strong> we're keeping VMOperator's scope narrow on purpose. Resizing a running VM, stopping and restarting one, taking snapshots, and supporting more than one real provider behind <code>ProviderConfig</code> are all natural extensions of what's here, and a reasonable next step once the core loop feels solid.</p>
<h2 id="heading-part-4-production-amp-deployment">Part 4: Production &amp; Deployment</h2>
<p>Now that VMOperator works, let's see how to package and deploy it and improve it for production.</p>
<h3 id="heading-packaging-amp-deployment">Packaging &amp; Deployment</h3>
<p>Everything so far has run as a binary on our own machine. <code>go run</code> against whatever cluster <code>kubectl</code> happens to be pointed at.</p>
<p>A <code>Deployment</code> needs an image instead, so the operator gets a multi-stage <code>Dockerfile</code>: one stage to compile it, and a second, much smaller image to actually run it:</p>
<pre><code class="language-dockerfile">FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /vmoperator ./cmd/manager

FROM gcr.io/distroless/static-debian12
COPY --from=build /vmoperator /vmoperator
USER 65532:65532 # nonroot, matches the security context on the Deployment below
ENTRYPOINT ["/vmoperator"]
</code></pre>
<p>The build stage has the full Go toolchain and every source file, none of which need to ship. The final image only has the compiled binary, which covers most of what a container security context later in this part would otherwise have to ask for. There's no shell to get a foothold in even before <code>runAsNonRoot</code> is set.</p>
<pre><code class="language-bash">docker build -t registry.example.com/vmoperator:v0.1.0 .
docker push registry.example.com/vmoperator:v0.1.0
</code></pre>
<p>That image is what the <code>Deployment</code> manifest under <code>config/manager/</code> actually references. With it pushed somewhere the cluster can pull from, the rest of the manifests can go on: the CRDs, RBAC, the operator's <code>Deployment</code>, and a <code>Deployment</code> and <code>Service</code> for the mock provider. So it's no longer something we run as a side process on our own machine either:</p>
<pre><code class="language-bash">kubectl apply -f config/crd/
kubectl apply -f config/rbac/
kubectl apply -f config/manager/
</code></pre>
<p>Installing the CRDs before anything else matters. Otherwise the operator's <code>Deployment</code> will crash-loop if it starts and immediately (it tries to watch a resource type the API server has never heard of).</p>
<p>Schema changes are the part that hand-written manifests make us feel directly. Adding a field to <code>VirtualMachineSpec</code> is harmless, but existing objects just don't have it set. Renaming or restructuring one isn't: every stored <code>VirtualMachine</code> was serialized against the old shape.</p>
<p>The CRD's <code>versions</code> list is built for exactly this, as more than one version can be <code>served</code> at once. One is marked <code>storage</code> to say which shape objects are actually persisted as, and a conversion webhook translates between them when a client asks for a version that isn't the stored one.</p>
<p>We don't need this for VMOperator today, since <code>v1</code> is the only version that's ever existed. But it's why the <code>versions</code> field was a list and not a single value from the very first manifest we wrote.</p>
<p>None of the above replaces a person running <code>kubectl apply</code> by hand forever. A CI pipeline that builds the operator's image, pushes it, and applies the manifests on merge to main is the natural next step. This is ordinary CI/CD, nothing operator-specific about it once the manifests themselves are in Git.</p>
<h3 id="heading-performance-amp-resilience">Performance &amp; Resilience</h3>
<p>By default, a controller only processes one reconcile at a time. That's fine while we're the only ones testing it, but with hundreds of <code>VirtualMachine</code> objects it means that most of them sit in the workqueue waiting their turn even though nothing about reconciling one blocks reconciling another.</p>
<p><code>MaxConcurrentReconciles</code> raises that:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Owns(&amp;corev1.Secret{}).
		Owns(&amp;corev1.Service{}).
		Watches(&amp;computev1.ProviderConfig{}, handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig)).
		WithOptions(controller.Options{MaxConcurrentReconciles: 5}). // five VMs in flight instead of one
		Complete(r)
}
</code></pre>
<p>Caching only helps one side of this reconciler. Reading <code>vm</code> back from <code>r.Get</code> is already fast and local, as informers keep that in memory. But <code>r.Provider.Get</code> is a real HTTP round trip every single time, and there's no cache in front of it.</p>
<p>That asymmetry is worth sitting with, because it's the same one from Part 2: in-cluster reads are cheap because Kubernetes built the caching layer for us, and external reads are exactly as expensive as whatever's on the other end of the wire. We could add a short-lived cache in front of the provider client, but it comes with a real cost: a cached <code>Running</code> for a VM that just failed is a lie our status will repeat until the cache expires.</p>
<p>The provider not having a cache in front of it also means nothing is stopping us from hammering it. A burst of reconciles, say every <code>VirtualMachine</code> getting touched at once after a cluster restart, turns into a burst of HTTP calls with no coordination between them. Wrapping the client in a rate limiter caps that independently of whatever backoff the workqueue is already doing on failures:</p>
<pre><code class="language-go">type Client struct {
	baseURL string
	http    *http.Client
	limiter *rate.Limiter // shared across every reconcile using this client
}

func (c *Client) Create(ctx context.Context, image string) (*VM, error) {
	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}
	// ... existing HTTP call
}
</code></pre>
<p>Leader election is the other half of running more than one replica safely. We turned this down to a concept in Part 2, but in practice it's two fields on the manager:</p>
<pre><code class="language-go">mgr, err := ctrl.NewManager(cfg, ctrl.Options{
	LeaderElection:   true,
	LeaderElectionID: "vmoperator-leader",
})
</code></pre>
<p>With this set, every replica starts up, but only the one holding the lease actually reconciles. The rest sit ready to take over the moment it doesn't renew in time.</p>
<p>Concurrency also surfaces a race we glossed over in Part 3. Say the reconciler calls <code>r.Provider.Create</code>, the provider creates the VM and returns its id, and then the process crashes before <code>r.Status().Update</code> ever runs. <code>vm.Status.ID</code> is still empty, so the next reconcile sees an object with no VM yet and calls <code>Create</code> again. Now the provider has two VMs for one <code>VirtualMachine</code>.</p>
<p>Nothing about <code>MaxConcurrentReconciles</code> or leader election prevents this. It's a gap in the create step itself, and it only shows up once something can fail between the external call and the write that records it.</p>
<p>Closing it for real means the provider needs to accept an idempotency key, generated once and stored on the object before the first <code>Create</code> call, so a retried create recognizes that it already happened instead of making a second VM.</p>
<h3 id="heading-security">Security</h3>
<p>The <code>ClusterRole</code> from Part 3 works, but it's broader than it needs to be. It grants every verb on <code>secrets</code> and <code>services</code> cluster-wide, when the operator only ever touches the ones it owns.</p>
<p>A tighter version scopes to a single namespace with <code>Role</code>/<code>RoleBinding</code> instead of <code>ClusterRole</code>/<code>ClusterRoleBinding</code> wherever VMOperator is only expected to run in one, and it drops verbs we never call. We never <code>list</code> or <code>watch</code> arbitrary <code>Secret</code>s outside our own, only the ones we create. This is also the RBAC the manifests applied in the previous section were referring to.</p>
<p>Credentials are the other gap. <code>ProviderConfig</code> currently holds a plaintext endpoint, and a real provider needs an API key alongside it, which has no business sitting in a CRD spec anyone with read access to the object can see. It belongs in a <code>Secret</code>, referenced by name instead of embedded:</p>
<pre><code class="language-go">type ProviderConfigSpec struct {
	Endpoint  string                      `json:"endpoint"`
	SecretRef corev1.LocalObjectReference `json:"secretRef"` // Secret holding the provider's API key
}
</code></pre>
<p>The reconciler resolves <code>SecretRef</code> at the point it builds the provider client, reads the key out of the <code>Secret</code>'s data, and never logs it or writes it back to anything with wider read access, including the <code>VirtualMachine</code>'s own status.</p>
<p>The last piece is the operator's own pod. A container security context that runs as a non-root user sets a read-only root filesystem, drops Linux capabilities it doesn't need, and shrinks what's possible if the binary itself is ever compromised. This is standard practice for any workload, not something specific to operators.</p>
<p>If we'd added an admission webhook anywhere in this guide, its certificates would belong here too. We didn't need one for VMOperator, so we'll leave that as a pointer rather than something to configure.</p>
<h3 id="heading-observability">Observability</h3>
<p>The manager exposes a Prometheus endpoint without us writing anything for it. Workqueue depth, reconcile duration, and reconcile error counts are already there per controller.</p>
<p>What isn't there automatically is anything about the provider, so we add a metric the same way any Go service would:</p>
<pre><code class="language-go">var providerCallDuration = prometheus.NewHistogramVec(
	prometheus.HistogramOpts{
		Name: "vmoperator_provider_call_duration_seconds",
		Help: "Duration of calls to the VM provider, by operation",
	},
	[]string{"operation"},
)

func init() {
	metrics.Registry.MustRegister(providerCallDuration) // shares the manager's existing /metrics endpoint
}
</code></pre>
<p>Wrapping each provider call with a timer around this turns "is the provider slow" from a question we'd have to guess at into one we can graph.</p>
<p>Logging benefits from the same instinct. <code>log.FromContext(ctx)</code> inside <code>Reconcile</code> already carries the <code>VirtualMachine</code>'s name and namespace on every line if we set that up once in <code>SetupWithManager</code>. Adding <code>vm.Status.ID</code> to that logger right after it's set means every subsequent log line for that reconcile also carries the provider's own identifier for the VM. That one field is what makes it possible to grep a mock provider log and an operator log for the same request and find both sides of the same failure.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>In this guide, you learned what a Kubernetes operator is, how to build one from scratch, and how to prepare it for production. You also learned about finalizers, predicates, owned resources, and cross-resource reconciliation along the way.</p>
<p>None of this is specific to managing VMs. The next operator, whatever it manages, is the same shape.</p>
<p>You can also review the resources below to keep learning:</p>
<ul>
<li><p><a href="https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/">K8s custom resources</a></p>
</li>
<li><p><a href="https://github.com/kubernetes/client-go">client-go</a></p>
</li>
<li><p><a href="https://github.com/kubernetes-sigs/controller-runtime">controller-runtime</a></p>
</li>
<li><p><a href="https://docs.docker.com/build/">Docker docs</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Product Experimentation with Instrumental Variables: Unconfounding LLM Routing Decisions in Python ]]>
                </title>
                <description>
                    <![CDATA[ For data science leaders and product managers who are overseeing multi-model gateways, the standard regression approach to measuring model quality is fundamentally flawed. You're running a causal infe ]]>
                </description>
                <link>https://www.freecodecamp.org/news/instrumental-variables-for-llm-routing-in-python/</link>
                <guid isPermaLink="false">6a69ffcc634c4a299b014f9f</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ instrumental-variables ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 13:27:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9b1e9df5-6f52-4f55-b9df-6cd0fbb0ce7c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>For data science leaders and product managers who are overseeing multi-model gateways, the standard regression approach to measuring model quality is fundamentally flawed.</p>
<p>You're running a causal inference experiment whether you acknowledge it or not, and your routing rules are quietly poisoning your performance estimates.</p>
<p>Consider a gateway that routes incoming queries to either a premium model or a faster, cheaper alternative based on a confidence threshold. Queries with a confidence score below a certain threshold get routed premium, while queries above that threshold go cheap.</p>
<p>You pull the logs, run a regression of <code>task_completed</code> on the routing decision, and find that premium routing yields a 14-percentage-point lift. Based on this number, your infrastructure team might start drafting a proposal to route everything premium.</p>
<p>Stop before you send that proposal. The routing rule correlates strongly with query complexity, which directly determines whether a task is completed. Complex queries are harder and fail more often, regardless of which model handles them.</p>
<p>When you regress task completion on premium routing, you measure two entangled phenomena simultaneously: the causal effect of sending a query to the premium model, and the inherent difference in difficulty between the queries each model receives.</p>
<p>Standard regression blends those two signals into a single coefficient, and the observed lift reflects query difficulty just as much as it reflects model quality.</p>
<p>The routing confounder arises whenever assignment correlates with query characteristics, as is to be expected in any routing system doing its job. The assignment rule ensures that the two treatment arms contain systematically different queries, invalidating the naïve comparison as a causal estimate.</p>
<p>Instrumental variable analysis is the method that breaks this deadlock. You need a third variable that influences routing for reasons completely unrelated to query quality.</p>
<p>Rate-limit-triggered fallbacks are exactly that. When the premium model hits a rate limit, the gateway reroutes the query to the cheaper model regardless of the query's characteristics. The rate limit fires for infrastructure reasons, independent of what a user actually asked. That randomness is an instrument, and two-stage least squares (2SLS) lets you extract a clean causal estimate from it.</p>
<p>This tutorial walks through the full diagnosis-to-fix sequence in Python: why the routing confounder biases OLS, how to build 2SLS from scratch across two chained regressions, how to check instrument strength with the first-stage F-statistic, and how to recover the local average treatment effect that 2SLS actually estimates rather than mistaking it for the average treatment effect. By the end, you'll know how to spot a confounded routing decision in your own logs, construct a valid instrument from an infrastructure signal like rate-limit fallbacks, and produce a causal estimate with correctly sized confidence intervals instead of the overconfident ones manual 2SLS gives you by default.</p>
<p><strong>Companion notebook</strong>: every code block in this article runs end-to-end in <code>iv_demo.ipynb</code> in the companion repo at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/"><code>github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/</code></a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-routing-confounds-regression">Why Routing Confounds Regression</a></p>
</li>
<li><p><a href="#heading-what-an-instrumental-variable-is">What an Instrumental Variable is</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-setting-up-the-working-example">Setting Up the Working Example</a></p>
</li>
<li><p><a href="#heading-step-1-naive-ols-biased-baseline">Step 1: Naïve OLS (Biased Baseline)</a></p>
</li>
<li><p><a href="#heading-step-2-two-stage-least-squares-2sls-from-scratch">Step 2: Two-Stage Least Squares (2SLS) from Scratch</a></p>
</li>
<li><p><a href="#heading-step-3-weak-instrument-diagnostics">Step 3: Weak-Instrument Diagnostics</a></p>
</li>
<li><p><a href="#heading-step-4-the-late-is-the-quantity-you-actually-care-about">Step 4: The LATE is the Quantity You Actually Care About</a></p>
</li>
<li><p><a href="#heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</a></p>
</li>
<li><p><a href="#heading-when-instrumental-variables-fail">When Instrumental Variables Fail</a></p>
</li>
<li><p><a href="#heading-what-to-do-next">What to Do Next</a></p>
</li>
</ul>
<h2 id="heading-why-routing-confounds-regression">Why Routing Confounds Regression</h2>
<p>A routing system makes a correlated decision. Queries that arrive with low confidence scores, long token counts, or complex multi-step intent get routed to premium. Queries that are short, clear, and well within the cheap model's capability get routed cheap. That correlation is the whole point of the routing layer.</p>
<p>The problem is that the same features driving the routing decision also affect the outcome you care about.</p>
<p>Task completion is harder for complex queries, independent of which model processes them. When you write <code>task_completed ~ routed_to_premium + controls</code>, the <code>controls</code> term can absorb the observable dimensions of complexity: query length, user engagement tier, and whatever you logged.</p>
<p>The unobservable dimensions stay embedded in the <code>routed_to_premium</code> coefficient, and they bias the estimate downward (complex queries routed premium complete less often, making premium look worse than it is) or upward, depending on the direction of the confound.</p>
<p>In the synthetic dataset used in this tutorial, the OLS estimate lands at +3.3 percentage points even though the true causal effect is +6 percentage points. This is a downward bias of 2.7 pp driven entirely by unobserved query complexity.</p>
<p>The regression looks confident, the p-value looks significant, and nothing in the standard OLS output flags the problem. That's what makes this failure mode dangerous: it's invisible in standard regression diagnostics.</p>
<p>2SLS is built for exactly this structure. You need an external source of variation in routing that is uncorrelated with query quality. Rate-limit-triggered fallbacks provide it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/dcb88e06-c217-47f7-b220-a3c3184c84e1.png" alt="dcb88e06-c217-47f7-b220-a3c3184c84e1" style="display:block;margin:0 auto" width="1485" height="885" loading="lazy">

<p><em>Figure 1: The IV causal structure. The instrument (Z = rate-limit fallback) satisfies relevance (Z predicts routing), exclusion (no direct Z to outcome path), and independence (Z is uncorrelated with unobserved query complexity). The dashed red arrows show the confounder paths that bias naïve OLS.</em></p>
<h2 id="heading-what-an-instrumental-variable-is">What an Instrumental Variable is</h2>
<p>An instrument is a variable that shifts your endogenous variable (routing decision) without any other direct path to your outcome (task completion).</p>
<p>Four assumptions define a valid instrument.</p>
<h3 id="heading-relevance">Relevance</h3>
<p>The instrument must actually influence the endogenous variable. A rate-limit fallback indicator that fires on 15 percent of premium-eligible queries will meaningfully affect whether those queries get routed to premium.</p>
<p>This assumption is testable: check it with the first-stage F-statistic. The conventional threshold is F &gt; 10, established by <a href="https://ideas.repec.org/a/ecm/emetrp/v65y1997i3p557-586.html">Staiger and Stock (1997)</a>, corresponding to approximately a 10% maximum bias in the 2SLS estimator relative to OLS in the worst case.</p>
<p>Note that more recent work by <a href="https://ideas.repec.org/a/anr/reveco/v11y2019p727-753.html">Andrews, Stock, and Sun (2019)</a> suggests this threshold may be too permissive in settings with smaller samples or multiple instruments. For production analyses with limited fallback data, treat F &gt; 10 as a minimum floor and verify with additional sensitivity checks before reporting results. Below 10, the instrument is definitively weak, and the estimate is unreliable.</p>
<h3 id="heading-exclusion-restriction">Exclusion Restriction</h3>
<p>The instrument must affect the outcome solely through its effect on routing. The rate-limit fallback completes the task entirely by changing which model handles the query, with no separate direct path.</p>
<p>This assumption requires logical business reasoning and can't be verified from data alone. A fallback triggered by aggregate infrastructure load is unrelated to what a user asked or how hard their task was.</p>
<h3 id="heading-independence">Independence</h3>
<p>The instrument must be independent of all confounders. Rate-limit events are driven by aggregate API traffic and are unrelated to the characteristics of any individual query. The probability that a given query triggers a rate-limit fallback is uncorrelated with query complexity, user tier, or any other confounder. This assumption too must be argued logically.</p>
<h3 id="heading-monotonicity">Monotonicity</h3>
<p>The instrument must move all affected units in the same direction. For rate-limit fallbacks, every affected query switches from premium to cheap, but no query switches from cheap to premium due to a fallback. This rules out defiers and is required for the LATE interpretation to hold.</p>
<p>When all four hold, 2SLS extracts a causal estimate of routing's effect on task completion by using only the exogenous variation in routing generated by the instrument.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You need:</p>
<ul>
<li><p>Python 3.11 or newer</p>
</li>
<li><p>Comfort with pandas and statsmodels OLS</p>
</li>
<li><p>Rough familiarity with linear regression (2SLS is two OLS regressions chained together)</p>
</li>
</ul>
<p>Install the packages for this tutorial:</p>
<pre><code class="language-bash">pip install numpy pandas statsmodels scipy
</code></pre>
<p>This installs the four packages used in the tutorial. <code>statsmodels</code> provides OLS and the formula API, and <code>scipy</code> is used for statistical computations in the bootstrap step.</p>
<p>Clone the companion repo to get the synthetic dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
</code></pre>
<p>You clone the companion repo and regenerate the shared 50,000-user synthetic dataset with a fixed seed so your results match the expected outputs in this article.</p>
<h2 id="heading-setting-up-the-working-example">Setting Up the Working Example</h2>
<p>This tutorial adds three simulated variables on top of the shared dataset's user covariates, constructing the full IV causal graph in code:</p>
<ul>
<li><p><code>rate_limit_fallback</code>: the instrument Z. Sampled as a pure Bernoulli(0.15), completely independent of all query characteristics.</p>
</li>
<li><p><code>routed_to_premium_actual</code>: the endogenous treatment D. Routing is driven by both <code>query_confidence</code> (observable) and <code>query_complexity</code> (unobservable), so OLS is biased.</p>
</li>
<li><p><code>task_completed_iv</code>: the outcome Y. Re-simulated from the IV causal graph with a known +6 pp premium routing effect, letting you verify that the estimator recovers the ground truth.</p>
</li>
</ul>
<p>A transparency note: in a real production analysis, the rate-limit fallback events come from your API gateway logs. Your user telemetry table won't have them. You'd join those two sources to construct the instrument.</p>
<p>The simulation here preserves the structural properties of a real instrument: it fires for infrastructure reasons, independent of query quality, without requiring production gateway logs.</p>
<pre><code class="language-python">import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

np.random.seed(42)

df = pd.read_csv("data/synthetic_llm_logs.csv")
rng = np.random.default_rng(99)
n = len(df)

# Unobserved confounder: complex queries route premium AND complete less often
query_complexity = rng.normal(0, 1, n)

# Endogenous routing: depends on query_confidence (observable)
# and query_complexity (unobserved): this is the confounding structure
log_odds = -2.0 + 4.0 * (1.0 - df["query_confidence"]) + 0.6 * query_complexity
premium_prob = 1.0 / (1.0 + np.exp(-log_odds))
df["routed_to_premium_iv"] = rng.binomial(1, premium_prob).astype(int)

# Instrument: pure Bernoulli(0.15), independent of all query characteristics
df["rate_limit_fallback"] = rng.binomial(1, 0.15, n)

# Actual routing: premium if intended, unless fallback overrides
df["routed_to_premium_actual"] = (
    df["routed_to_premium_iv"] * (1 - df["rate_limit_fallback"])
).astype(int)

# Outcome: known causal structure with +0.06 premium effect
engagement_base = np.where(df.engagement_tier == "heavy", 0.70,
                  np.where(df.engagement_tier == "medium", 0.55, 0.35))
completion_prob = np.clip(
    engagement_base
    + 0.06 * df["routed_to_premium_actual"]  # true causal effect
    - 0.04 * query_complexity                 # unobserved confounder
    + rng.normal(0, 0.02, n),
    0.01, 0.99
)
df["task_completed_iv"] = rng.binomial(1, completion_prob).astype(int)

# Encode engagement tier as dummies
df = pd.get_dummies(df, columns=["engagement_tier"], drop_first=True)
tier_dummies = [c for c in df.columns if c.startswith("engagement_tier_")]
covariate_str = " + ".join(["query_confidence"] + tier_dummies)

print(f"Rate-limit fallback rate:      {df.rate_limit_fallback.mean():.3f}")
print(f"Premium routing rate (actual): {df.routed_to_premium_actual.mean():.3f}")
print(f"Mean confidence | fallback=1:  {df[df.rate_limit_fallback==1].query_confidence.mean():.3f}")
print(f"Mean confidence | fallback=0:  {df[df.rate_limit_fallback==0].query_confidence.mean():.3f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Rate-limit fallback rate:      0.151
Premium routing rate (actual): 0.271
Mean confidence | fallback=1:  0.716
Mean confidence | fallback=0:  0.715
</code></pre>
<p>In the above code, the nearly identical mean confidence scores between the fallback=1 and fallback=0 groups confirm that the instrument is independent of the observable routing signal. This is the independence assumption check you can run on any proposed instrument. <code>query_complexity</code> is available in this simulation but would be unobserved in production. The regression never receives it.</p>
<h2 id="heading-step-1-naive-ols-biased-baseline">Step 1: Naïve OLS (Biased Baseline)</h2>
<p>Running a standard regression first establishes the biased baseline you'd encounter without accounting for the confounding structure. Most engineering teams report this number without realizing it's mathematically compromised.</p>
<pre><code class="language-python">ols_formula = f"task_completed_iv ~ routed_to_premium_actual + {covariate_str}"
ols_model = smf.ols(ols_formula, data=df).fit(cov_type="HC3")

ols_coef = ols_model.params["routed_to_premium_actual"]
ols_se   = ols_model.bse["routed_to_premium_actual"]
ols_pval = ols_model.pvalues["routed_to_premium_actual"]
print(f"OLS estimate of premium routing effect: {ols_coef:+.4f}")
print(f"HC3 standard error:                      {ols_se:.4f}")
print(f"p-value:                                 {ols_pval:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS estimate of premium routing effect: +0.0327
HC3 standard error:                      0.0050
p-value:                                 0.0000
</code></pre>
<p>Here's what's happening: OLS recovers +3.3 percentage points (probability units, since <code>task_completed_iv</code> is a 0/1 binary outcome in a linear probability model). The true causal effect is +6.0 pp. The 2.7 pp bias comes from unobserved query-complexity routing: harder queries are routed to premium, and they complete less often, which is a downward confounding mechanism. The p-value looks significant, and the standard error looks precise. Nothing in this output tells you the estimate is wrong.</p>
<p>Keep this number in mind: the 2SLS result in Step 2 will reveal the gap.</p>
<h2 id="heading-step-2-two-stage-least-squares-2sls-from-scratch">Step 2: Two-Stage Least Squares (2SLS) from Scratch</h2>
<p>Two-stage least squares corrects the bias by isolating the exogenous routing variation generated by rate-limit fallbacks, using only that variation to estimate the causal effect.</p>
<h3 id="heading-stage-1-predict-routing-from-the-instrument-and-covariates">Stage 1: Predict Routing from the Instrument and Covariates.</h3>
<pre><code class="language-python">stage1_formula = f"routed_to_premium_actual ~ rate_limit_fallback + {covariate_str}"
stage1 = smf.ols(stage1_formula, data=df).fit(cov_type="HC3")

print(f"Stage 1 instrument coefficient: {stage1.params['rate_limit_fallback']:+.4f}")
print(f"p-value:                         {stage1.pvalues['rate_limit_fallback']:.4f}")

df["rtp_hat"] = stage1.fittedvalues
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Stage 1 instrument coefficient: -0.3190
p-value:                         0.0000
</code></pre>
<p>In this code, you regress the endogenous routing variable on the instrument and the same observed covariates you'll use in Stage 2.</p>
<p>The fitted values <code>rtp_hat</code> contain two components: the exogenous variation the instrument explains, and the exogenous variation the covariates explain.</p>
<p>The endogenous component (the variation correlated with unobserved query complexity) stays in the residuals and drops out of <code>rtp_hat</code>. The negative coefficient on <code>rate_limit_fallback</code> confirms the relevance assumption: when the fallback fires, premium routing probability drops by about 32 percentage points.</p>
<h3 id="heading-stage-2-regress-outcome-on-the-predicted-routing">Stage 2: Regress Outcome on the Predicted Routing.</h3>
<pre><code class="language-python">stage2_formula = f"task_completed_iv ~ rtp_hat + {covariate_str}"
stage2 = smf.ols(stage2_formula, data=df).fit(cov_type="HC3")

tsls_coef = stage2.params["rtp_hat"]
tsls_se   = stage2.bse["rtp_hat"]
print(f"2SLS estimate:              {tsls_coef:+.4f}")
print(f"Stage-2 SE (underestimate): {tsls_se:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">2SLS estimate:              +0.0599
Stage-2 SE (underestimate): 0.0188
</code></pre>
<p>Here's what's happening: replacing <code>routed_to_premium_actual</code> with <code>rtp_hat</code> removes the endogenous part of the routing variation. The Stage 2 coefficient (+0.0599) is the 2SLS estimate of the causal effect of premium routing on task completion, almost exactly the +0.06 ground truth.</p>
<p>Here's an important caveat on standard errors: manual 2SLS produces Stage 2 SEs that are too small. Stage 2 OLS treats <code>rtp_hat</code> as a fixed, known regressor, when in fact it was estimated from the data in Stage 1. That estimation error adds a variance component that Stage 2's residuals never see.</p>
<p>For any result you report to stakeholders, use <code>linearmodels.IV2SLS</code> (shown in "What to do next"), which computes the correct sandwich variance.</p>
<h3 id="heading-compare-ols-and-2sls-side-by-side">Compare OLS and 2SLS Side by Side:</h3>
<pre><code class="language-python">print(f"OLS estimate (biased):  {ols_coef:+.4f}")
print(f"2SLS estimate (IV):     {tsls_coef:+.4f}")
print(f"True premium effect:   +0.0600")
print(f"OLS bias:               {ols_coef - 0.06:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS estimate (biased):  +0.0327
2SLS estimate (IV):     +0.0599
True premium effect:   +0.0600
OLS bias:               -0.0273
</code></pre>
<p>Here, OLS misses the true effect by 2.7 pp, a 45% underestimate. 2SLS recovers it to within 0.01 pp. The direction of the gap matches the confounding mechanism: unobserved query complexity routes hard queries to premium and reduces their completion, pulling the OLS coefficient downward.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/dc39aa79-c955-4c4e-9962-f5a648d6e383.png" alt="dc39aa79-c955-4c4e-9962-f5a648d6e383" style="display:block;margin:0 auto" width="1633" height="763" loading="lazy">

<p><em>Figure 2: Data-driven results on the 50,000-user synthetic dataset. Left panel: routing rates by fallback group confirm the first-stage relationship: fallback=0 queries route premium at 39.1%, fallback=1 queries at 0% (complete override). Right panel: OLS CI (red) misses the true +0.06 pp effect entirely. 2SLS CI (green) covers it. The wider 2SLS interval reflects the variance cost of relying solely on the instrument's exogenous variation.</em></p>
<h2 id="heading-step-3-weak-instrument-diagnostics">Step 3: Weak-Instrument Diagnostics</h2>
<p>A valid instrument that has little effect on outcomes is a weak instrument. Weak instruments produce 2SLS estimates with enormous variance that drift toward the OLS estimate in small samples, which defeats the purpose. The standard diagnostic is the first-stage F-statistic.</p>
<pre><code class="language-python">stage1_restricted = smf.ols(
    f"routed_to_premium_actual ~ {covariate_str}", data=df
).fit()

f_stat, f_pval, _ = stage1.compare_f_test(stage1_restricted)
print(f"First-stage F-statistic (instrument): {f_stat:.2f}")
print(f"p-value:                               {f_pval:.4f}")

if f_stat &gt; 10:
    print("Instrument is STRONG (F &gt; 10). 2SLS estimates are reliable.")
elif f_stat &gt; 4:
    print("Instrument is BORDERLINE WEAK (4 &lt; F &lt; 10). Interpret with caution.")
else:
    print("Instrument is WEAK (F &lt; 4). 2SLS estimates are unreliable.")

print(f"\nFirst-stage coefficient on instrument: "
      f"{stage1.params['rate_limit_fallback']:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">First-stage F-statistic (instrument): 3780.94
p-value:                               0.0000
Instrument is STRONG (F &gt; 10). 2SLS estimates are reliable.

First-stage coefficient on instrument: -0.3190
</code></pre>
<p>In the above code, you compare the full Stage 1 model (with the instrument) to a restricted model (without it) using an F-test. An F of 3780 is overwhelmingly above the Staiger-Stock rule of thumb. The 15% fallback rate applied to 50,000 observations yields a large, precisely estimated first-stage effect.</p>
<p>On a real production dataset with lower fallback rates or a smaller dataset, the F-statistic will be lower. If you get an F-statistic below 10, either find a stronger instrument or add more fallback data before drawing conclusions.</p>
<p>There's a trade-off between instrument strength and exclusion validity that's worth flagging explicitly. You can make an instrument stronger by increasing the fallback rate, but if you push it high enough to affect user experience, the fallback starts to directly affect task completion through satisfaction and retry behavior, which violates the exclusion restriction. A strong instrument that satisfies both relevance and exclusion is the goal.</p>
<p>The endogeneity direction check:</p>
<pre><code class="language-python">gap = ols_coef - tsls_coef
print(f"OLS minus 2SLS gap: {gap:+.4f}")
if abs(gap) &gt; 0.005:
    print("Gap suggests endogeneity bias is present in OLS.")
else:
    print("Small gap: OLS and 2SLS broadly agree.")
print("For a formal Hausman endogeneity test, use linearmodels IV2SLS.")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS minus 2SLS gap: -0.0272
Gap suggests endogeneity bias is present in OLS.
For a formal Hausman endogeneity test, use linearmodels IV2SLS.
</code></pre>
<p>Here's what's happening: the gap between OLS and 2SLS is the diagnostic for endogeneity. A gap of 2.7 pp confirms that the routing variable is genuinely correlated with unobserved confounders, and that OLS was absorbing part of the confounder's effect.</p>
<p>For a formally valid Hausman test (one that produces a chi-squared statistic with a known distribution under the null), use <code>linearmodels.IV2SLS</code>'s built-in test. The direction check above is a quick diagnostic only.</p>
<h2 id="heading-step-4-the-late-is-the-quantity-you-actually-care-about">Step 4: The LATE is the Quantity You Actually Care About</h2>
<p>2SLS estimates the Local Average Treatment Effect (LATE), also called the Complier Average Causal Effect (CACE). The LATE applies only to compliers: the specific subset of queries whose routing actually changes when the instrument fires. Rate-limit fallbacks affect only premium-eligible queries that experience a fallback, so the LATE is specific to that subpopulation.</p>
<pre><code class="language-python">compliers_mask = df["rate_limit_fallback"] == 1
complier_count = compliers_mask.sum()
complier_pct   = complier_count / n * 100

print(f"Approximate complier population: {complier_count:,} ({complier_pct:.1f}% of queries)")
print(f"\nComplier mean confidence:     {df[compliers_mask]['query_confidence'].mean():.3f}")
print(f"Non-complier mean confidence: {df[~compliers_mask]['query_confidence'].mean():.3f}")
print(f"\n2SLS LATE estimate: {tsls_coef:+.4f}")
print("This is the causal effect of premium routing for queries rerouted")
print("by rate-limit fallbacks, not all queries in the dataset.")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Approximate complier population: 7,575 (15.2% of queries)

Complier mean confidence:     0.716
Non-complier mean confidence: 0.715

2SLS LATE estimate: +0.0599
This is the causal effect of premium routing for queries rerouted
by rate-limit fallbacks, not all queries in the dataset.
</code></pre>
<p>In this code, the complier population is 7,575 queries (those that experienced a rate-limit fallback and were rerouted from premium to cheap). Their mean confidence (0.716) is nearly identical to the non-complier group (0.715), confirming that the fallback fired independently of query characteristics.</p>
<p>When compliers look like a representative slice of all queries on observables, the LATE is often a reasonable approximation of the average treatment effect (ATE).</p>
<p>Observable representativeness meets the minimum diagnostic standard. The formal LATE-to-ATE condition requires either homogeneous treatment effects across all units or a valid instrument for every unit in the population. If your routing effect is heterogeneous across query types (premium routing helps complex queries far more than simple ones, for instance), the LATE can diverge substantially from the ATE, even when the complier mean confidence looks similar to that of the non-complier group.</p>
<p>For strategic capacity planning, this is exactly the metric you need. When you ask whether to invest in greater premium model capacity or adjust rate limits, you're asking a specific question about the queries currently constrained by your infrastructure. 2SLS answers that question directly.</p>
<h2 id="heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</h2>
<p>Manual 2SLS produces Stage 2 standard errors that are too small, as explained in Step 2. Bootstrap CIs give you reliable uncertainty estimates without needing to derive the correct analytic variance formula. The bootstrap resamples the full two-stage procedure together, capturing the sampling variance from both stages.</p>
<pre><code class="language-python">rng_boot = np.random.default_rng(7)
ols_boot, tsls_boot = [], []

for _ in range(500):
    samp = df.sample(len(df), replace=True,
                     random_state=int(rng_boot.integers(1_000_000_000)))

    # OLS bootstrap
    ols_b = smf.ols(
        f"task_completed_iv ~ routed_to_premium_actual + {covariate_str}",
        data=samp
    ).fit()
    ols_boot.append(ols_b.params["routed_to_premium_actual"])

    # 2SLS bootstrap (two stages together)
    s1b = smf.ols(
        f"routed_to_premium_actual ~ rate_limit_fallback + {covariate_str}",
        data=samp
    ).fit()
    samp = samp.copy()
    samp["rtp_hat"] = s1b.fittedvalues
    s2b = smf.ols(
        f"task_completed_iv ~ rtp_hat + {covariate_str}",
        data=samp
    ).fit()
    tsls_boot.append(s2b.params["rtp_hat"])

ols_ci  = (np.percentile(ols_boot, 2.5),  np.percentile(ols_boot, 97.5))
tsls_ci = (np.percentile(tsls_boot, 2.5), np.percentile(tsls_boot, 97.5))
true_eff = 0.0600

print(f"OLS  95% CI: [{ols_ci[0]:+.4f}, {ols_ci[1]:+.4f}]")
print(f"2SLS 95% CI: [{tsls_ci[0]:+.4f}, {tsls_ci[1]:+.4f}]")
print(f"Ground truth: +{true_eff:.4f}")
print(f"OLS CI covers ground truth:  {ols_ci[0] &lt;= true_eff &lt;= ols_ci[1]}")
print(f"2SLS CI covers ground truth: {tsls_ci[0] &lt;= true_eff &lt;= tsls_ci[1]}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS  95% CI: [+0.0227, +0.0426]
2SLS 95% CI: [+0.0247, +0.0969]
Ground truth: +0.0600
OLS CI covers ground truth:  False
2SLS CI covers ground truth: True
</code></pre>
<p>In this code, the OLS 95% CI ([+0.023, +0.043]) entirely misses the true +0.06 effect. Every value in that interval is below the ground truth: OLS is confidently wrong. The 2SLS CI ([+0.025, +0.097]) covers the ground truth. It's wider than the OLS interval, reflecting the variance cost of IV estimation: you pay in precision to gain in validity.</p>
<p>The bootstrap resamples both stages in each iteration, so the uncertainty correctly accounts for the two-stage structure. Use bootstrap CIs when reporting 2SLS results from a manual implementation, as they're more reliable than the Stage 2 parametric SE.</p>
<h2 id="heading-when-instrumental-variables-fail">When Instrumental Variables Fail</h2>
<p>IV analysis has failure modes more insidious than those of propensity scores or regression discontinuity, because two of the four assumptions are untestable from data alone.</p>
<h3 id="heading-weak-instruments">Weak Instruments</h3>
<p>A first-stage F below 10 signals a serious identification problem. Weak instruments cause the 2SLS estimator to have large variance and drift toward OLS in finite samples, replicating the biased baseline while appearing to do something more sophisticated. Check the F-statistic before interpreting any IV result.</p>
<p>If F is below 10, find a stronger instrument or report the estimate with an explicit weak-instrument warning. The instrument here is strong (F = 3780) because the 15% fallback rate applied to 50,000 queries yields 7,500+ routing changes.</p>
<h3 id="heading-exclusion-restriction-violations">Exclusion Restriction Violations</h3>
<p>If the rate-limit fallback affects task completion through any channel other than the routing decision, exclusion fails.</p>
<p>There are two plausible violations: fallback events cluster during high-traffic periods when users are also more likely to be doing complex batch jobs, making the instrument correlated with query difficulty after all. Or users who experience a fallback notice the degraded response quality and abandon the session, creating a direct Z to Y path through user frustration.</p>
<p>Both violate exclusion while leaving relevance intact. You can't test them from data. You have to argue from system knowledge.</p>
<h3 id="heading-late-vs-ate-confusion">LATE vs. ATE Confusion</h3>
<p>Using the LATE estimate to justify a broad routing policy change is wrong if compliers are atypical. If rate-limit fallbacks disproportionately hit complex queries (because complex queries take longer and are more likely to hit a rate limit mid-session), the LATE covers the causal effect of premium routing for that complex-query subpopulation.</p>
<p>Reporting it as if it were the ATE overstates the benefit of routing all queries premium. The complier characteristics table in Step 4 is the diagnostic: if compliers and non-compliers look similar on observables, the LATE is a credible approximation of the ATE.</p>
<h3 id="heading-defiers-and-the-monotonicity-assumption">Defiers and the Monotonicity Assumption</h3>
<p>The LATE interpretation requires monotonicity: the instrument moves all affected units in the same direction. For rate-limit fallbacks, this is almost certainly satisfied, since a fallback always reduces the probability of premium routing for the affected query.</p>
<p>If some compensating mechanism exists (say, a fallback: one query triggers a priority boost on the next), you have defiers, and the monotonicity assumption breaks down. Verify directional consistency before trusting the LATE.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>The manual 2SLS implementation in this tutorial is transparent about the mechanism but produces incorrect standard errors. For any result you report to stakeholders or include in a published analysis, use <code>linearmodels.IV2SLS</code>:</p>
<pre><code class="language-python"># Production-grade 2SLS with correct standard errors
# pip install linearmodels
from linearmodels.iv import IV2SLS

exog_vars = ["query_confidence"] + tier_dummies
iv_model = IV2SLS.from_formula(
    f"task_completed_iv ~ 1 + {' + '.join(exog_vars)} "
    f"[routed_to_premium_actual ~ rate_limit_fallback]",
    data=df
).fit(cov_type="robust")

print(iv_model.summary)
</code></pre>
<p>Here's what's happening: <code>linearmodels</code> computes the correct 2SLS variance that accounts for the two-stage structure, runs a proper first-stage diagnostic summary, and provides a formal Hausman endogeneity test. The syntax brackets the endogenous variable and instrument: <code>[D ~ Z]</code>.</p>
<p>The full implementation (including bootstrap confidence intervals and the visualization in Figure 2) is in the companion notebook at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/">github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/</a>. Clone the repo, generate the synthetic dataset, and run <code>iv_demo.ipynb</code> to reproduce every code block end-to-end.</p>
<p>One final note on when to reach for IV at all: if your system supports forced routing randomization (randomly assigning a fraction of queries to premium regardless of confidence score), a standard A/B test is simpler and produces a full-fleet ATE estimate.</p>
<p>IV is the right tool when randomization is infeasible: when the routing rule is baked into production logic, when you can't afford to deliberately route queries suboptimally, or when you need to use historical observational data. If you can run a true experiment, run it.</p>
<p>Confounding is the structural default for any optimized routing system. Standard regression folds model quality and inherent query difficulty into a single coefficient, measuring both at once when you need them separated.</p>
<p>Rate-limit fallbacks provide the clean, natural instrument that filters infrastructure noise from routing signal. This approach gives your team a defensible causal estimate of how your model architecture actually drives business value.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build AI Applications That Switch Models Automatically ]]>
                </title>
                <description>
                    <![CDATA[ Large Language Models (LLMs) have fundamentally changed how we build modern software. But relying on a single AI model for every user request creates serious production risks. API outages happen. Prop ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-ai-applications-that-switch-models-automatically/</link>
                <guid isPermaLink="false">6a69c635b68d550a815570fe</guid>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Data Science ]]>
                    </category>
                
                    <category>
                        <![CDATA[ large language models ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidiebere Njoku ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 09:21:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/521c4138-0d77-4fc3-8c39-8bfc7107a0ed.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Large Language Models (LLMs) have fundamentally changed how we build modern software.</p>
<p>But relying on a single AI model for every user request creates serious production risks. API outages happen. Proprietary models can be expensive for simple tasks. And cheaper open-source models might struggle with complex logical reasoning.</p>
<p>When my team and I built an enterprise-grade AI engine for our customer support platform, we relied on a single top-tier model for everything.</p>
<p>Within a month, we faced two massive issues: a widespread API outage completely froze our app, and our monthly API bill rose because we used expensive reasoning models to answer simple FAQs.</p>
<p>To fix this, I built a resilient, multi-model orchestrator. In this guide, you'll learn how to build an intelligent, multi-tiered AI application using Python that routes prompts dynamically and handles model fallbacks automatically.</p>
<ul>
<li><p><a href="#heading-what-well-cover">What We'll Cover</a></p>
</li>
<li><p><a href="#heading-prerequisites-and-environment-setup">Prerequisites and Environment Setup</a></p>
<ul>
<li><p><a href="#heading-package-installation">Package Installation</a></p>
</li>
<li><p><a href="#heading-local-directory-structure">Local Directory Structure</a></p>
</li>
<li><p><a href="#heading-environment-configuration">Environment Configuration</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-problem-with-single-model-architectures">The Problem with Single-Model Architectures</a></p>
</li>
<li><p><a href="#heading-understanding-the-dynamic-model-routing-lifecycle">Understanding the Dynamic Model Routing Lifecycle</a></p>
</li>
<li><p><a href="#heading-step-1-implementing-tier-1-prompt-complexity-amp-intent-analysis">Step 1: Implementing Tier 1 – Prompt Complexity &amp; Intent Analysis</a></p>
<ul>
<li><a href="#heading-breaking-down-the-code-logic-for-tier-1">Breaking Down the Code Logic for Tier 1</a></li>
</ul>
</li>
<li><p><a href="#heading-step-2-implementing-tier2-dynamic-model-routing-logic">Step 2: Implementing Tier2– Dynamic Model Routing Logic</a></p>
<ul>
<li><a href="#heading-breaking-down-the-code-logic-for-tier-2">Breaking Down the Code Logic for Tier 2</a></li>
</ul>
</li>
<li><p><a href="#heading-step-3-implementing-tier3-automatic-fallbacks">Step 3: Implementing Tier3 – Automatic Fallbacks</a></p>
<ul>
<li><p><a href="#heading-breaking-down-the-code-logic-for-tier-3">Breaking Down the Code Logic for Tier 3</a></p>
</li>
<li><p><a href="#heading-combining-the-architecture-into-a-unified-execution-pipeline">Combining the Architecture into a Unified Execution Pipeline</a></p>
</li>
<li><p><a href="#heading-breaking-down-the-code-logic">Breaking Down the Code Logic</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-lessons-learnt-from-dynamic-model-switching-in-production">Lessons Learnt from Dynamic Model Switching in Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
<ul>
<li><a href="#heading-thank-you-for-reading">Thank You for Reading!</a></li>
</ul>
</li>
</ul>
<h2 id="heading-prerequisites-and-environment-setup">Prerequisites and Environment Setup</h2>
<p>To follow along with this tutorial, you should have the following setup:</p>
<ul>
<li><p>Basic proficiency with Python and asynchronous programming.</p>
</li>
<li><p>Python 3.9 or higher installed on your system.</p>
</li>
<li><p>A code editor such as Visual Studio Code.</p>
</li>
<li><p>API keys for at least two model providers (for example, OpenAI and Anthropic), or local models running via Ollama.</p>
</li>
</ul>
<h3 id="heading-package-installation">Package Installation</h3>
<p>Open your terminal and install the required dependencies:</p>
<pre><code class="language-shell">pip install openai anthropic python-dotenv pydantic
</code></pre>
<h3 id="heading-local-directory-structure">Local Directory Structure</h3>
<p>Organize your project directory like this to keep your code clean:</p>
<pre><code class="language-plaintext">ai-model-router/

│

├── .env

├── README.md

└── app.py
</code></pre>
<h3 id="heading-environment-configuration">Environment Configuration</h3>
<p>Create a <code>.env</code> file in the root of your project directory and add your credentials:</p>
<pre><code class="language-plaintext">Ini, TOML

OPENAI_API_KEY=your_openai_api_key_here ANTHROPIC_API_KEY=your_anthropic_api_key_here ENVIRONMENT=development
</code></pre>
<h2 id="heading-the-problem-with-single-model-architectures">The Problem with Single-Model Architectures</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6a36420b52a5a7620def7e19/cbed7def-7a57-45c9-926a-1d6dce2aabb7.png" alt="A flow diagram illustrating a single-model AI architecture processed by one language model, creating a single point of failure and limiting cost optimization." style="display:block;margin:0 auto" width="940" height="857" loading="lazy">

<p>If you route every query to a flagship model like GPT-4o or Claude 3.5 Sonnet, you'd be overspending on simple tasks. Conversely, if you route everything to a smaller, faster model like GPT-4o-mini or Claude 3.5 Haiku to save money, your system will fail when users submit complex code-generation or analytical tasks.</p>
<p>On top of cost concerns, single-model systems suffer from single points of failure. When an API provider goes down or rate-limits your account, your entire application crashes.</p>
<p>To solve this, you need an orchestration layer that evaluates prompt complexity before invoking an LLM, routes the request to the most cost-effective model, and falls back to a secondary provider if the primary provider fails.</p>
<h2 id="heading-understanding-the-dynamic-model-routing-lifecycle">Understanding the Dynamic Model Routing Lifecycle</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6a36420b52a5a7620def7e19/e0bc213c-819b-477d-b0fe-e6dd42fac733.png" alt="Flow diagram of a dynamic multi-model AI system with intelligent model selection and automatic failover." style="display:block;margin:0 auto" width="863" height="936" loading="lazy">

<p>Here's how a user request journeys through a dynamic multi-model system:</p>
<p>First, you have the complexity analysis. The system inspects the incoming prompt using lightweight metrics to assign a task tier (Simple, Medium, or Complex).</p>
<p>Second, you have the model routing. The system maps the tier to the appropriate model (for example, lightweight tasks go to Haiku/Mini while heavy reasoning goes to Sonnet/GPT-4o).</p>
<p>You also have an automatic fallback: if the primary provider times out or throws an API error, the system automatically redirects the query to an equivalent fallback model.</p>
<h2 id="heading-step-1-implementing-tier-1-prompt-complexity-amp-intent-analysis">Step 1: Implementing Tier 1 – Prompt Complexity &amp; Intent Analysis</h2>
<p>First, you need a deterministic, fast way to classify prompts without making an expensive API call just to decide which model to use.</p>
<p>Before spending money on an LLM API call just to figure out what the user wants, we can look at the text directly in code. Think of this step as a smart gatekeeper. By checking simple things like text length, code snippets, or tricky keywords, we can figure out how hard the task is in milliseconds and for free.</p>
<p>Here's how we set up our classification rules inside <code>app.py</code>:</p>
<pre><code class="language-python">import re
from enum import Enum
from pydantic import BaseModel


class TaskComplexity(Enum):
    SIMPLE = "simple"      # FAQs, short summaries, basic translation
    MEDIUM = "medium"      # Standard text generation, content rewriting
    COMPLEX = "complex"    # Code writing, math logic, structural analysis


class PromptAnalyzer:
    def __init__(self):
        # Regex patterns indicative of complex tasks
        self.complex_keywords = [
            r"\brefactor\b",
            r"\bdebug\b",
            r"\bwrite code\b",
            r"\banalyze\b",
            r"\balgorithm\b",
            r"\barchitecture\b",
        ]

    def analyze_complexity(self, prompt: str) -&gt; TaskComplexity:
        """
        Evaluates input text deterministically to output
        a TaskComplexity rating.
        """
        normalized = prompt.lower().strip()
        word_count = len(normalized.split())

        # Check for code blocks or complex request patterns
        contains_code = "```" in prompt
        has_complex_keyword = any(
            re.search(pattern, normalized)
            for pattern in self.complex_keywords
        )

        if contains_code or has_complex_keyword or word_count &gt; 300:
            return TaskComplexity.COMPLEX
        elif word_count &gt; 80:
            return TaskComplexity.MEDIUM
        else:
            return TaskComplexity.SIMPLE


# Example Usage
if __name__ == "__main__":
    analyzer = PromptAnalyzer()

    test_prompt = (
        "Write a Python script that implements a trie "
        "data structure with autocomplete."
    )

    complexity = analyzer.analyze_complexity(test_prompt)
    print(f"Prompt Complexity Tier: {complexity.value}")
</code></pre>
<h3 id="heading-breaking-down-the-code-logic-for-tier-1">Breaking Down the Code Logic for Tier 1</h3>
<ul>
<li><p><code>TaskComplexity</code> <strong>Enum:</strong> Defines explicit categories for incoming requests (<code>SIMPLE</code>, <code>MEDIUM</code>, <code>COMPLEX</code>), giving us type safety across our pipeline.</p>
</li>
<li><p><strong>Keyword Matching:</strong> The <code>PromptAnalyzer</code> class sets up regex patterns looking for action words like <code>refactor</code>, <code>debug</code>, or <code>algorithm</code> that signal a heavy reasoning task.</p>
</li>
<li><p><strong>Deterministic Rules in</strong> <code>analyze_complexity</code><strong>:</strong></p>
</li>
<li><p>Formatting &amp; Length Check: We clean the string, check for Markdown code blocks (<code>```</code>), and calculate word counts.</p>
</li>
<li><p>Tier Allocation:</p>
<ul>
<li><p>If the prompt contains code blocks, trigger words, or exceeds 300 words, it immediately escalates to <code>COMPLEX</code>.</p>
</li>
<li><p>If it is between 80 and 300 words without code keywords, it maps to <code>MEDIUM</code>.</p>
</li>
<li><p>Anything shorter defaults to <code>SIMPLE</code>.</p>
</li>
</ul>
</li>
</ul>
<p>Running this snippet with a complex query checks the text, spots "write code," and outputs:</p>
<p>Prompt Complexity Tier: complex</p>
<h2 id="heading-step-2-implementing-tier2-dynamic-model-routing-logic">Step 2: Implementing Tier2– Dynamic Model Routing Logic</h2>
<p>Now that we can successfully label a prompt as simple, medium, or complex, we need a rulebook to decide which AI model actually handles it.</p>
<p>This layer maps each complexity tier to a primary model and a secondary fallback model. For instance, simple queries route to budget models (gpt-4o-mini), while complex requests route to heavyweights (claude-3-5-sonnet).</p>
<p>Add this configuration also:</p>
<pre><code class="language-python">class ModelConfig(BaseModel):
    provider: str
    model_name: str


class ModelRouter:
    def __init__(self):
        # Map task complexity tiers to primary and fallback models
        self.routing_table = {
            TaskComplexity.SIMPLE: {
                "primary": ModelConfig(
                    provider="openai",
                    model_name="gpt-4o-mini",
                ),
                "fallback": ModelConfig(
                    provider="anthropic",
                    model_name="claude-3-5-haiku-20241022",
                ),
            },
            TaskComplexity.MEDIUM: {
                "primary": ModelConfig(
                    provider="openai",
                    model_name="gpt-4o-mini",
                ),
                "fallback": ModelConfig(
                    provider="anthropic",
                    model_name="claude-3-5-haiku-20241022",
                ),
            },
            TaskComplexity.COMPLEX: {
                "primary": ModelConfig(
                    provider="anthropic",
                    model_name="claude-3-5-sonnet-20241022",
                ),
                "fallback": ModelConfig(
                    provider="openai",
                    model_name="gpt-4o",
                ),
            },
        }

    def get_models_for_tier(
        self, complexity: TaskComplexity
    ) -&gt; tuple[ModelConfig, ModelConfig]:
        """
        Returns the primary and fallback models for a given
        task complexity tier.
        """
        config = self.routing_table[complexity]
        return config["primary"], config["fallback"]
</code></pre>
<h3 id="heading-breaking-down-the-code-logic-for-tier-2">Breaking Down the Code Logic for Tier 2</h3>
<ul>
<li><p><code>ModelConfig</code> <strong>Schema:</strong> Uses Pydantic to ensure every model definition includes both a <code>provider</code> (for example, <code>"openai"</code>) and a specific <code>model_name</code> string.</p>
</li>
<li><p><code>self.routing_table</code> <strong>Mapping:</strong> This dictionary acts as our single source of truth for model assignments:</p>
<ul>
<li><p><code>SIMPLE</code> <strong>&amp;</strong> <code>MEDIUM</code> <strong>Tiers:</strong> Primary target is <code>gpt-4o-mini</code> for high-throughput, low-cost output. If OpenAI fails, it falls back to Anthropic's <code>claude-3-5-haiku-20241022</code>.</p>
</li>
<li><p><code>COMPLEX</code> <strong>Tier:</strong> Primary target flips to <code>claude-3-5-sonnet-20241022</code> for top-tier code generation and reasoning, with <code>gpt-4o</code> as the backup.</p>
</li>
</ul>
</li>
<li><p><code>get_models_for_tier</code><strong>:</strong> A helper function that takes the analyzed tier and safely returns a tuple of <code>(PrimaryModel, FallbackModel)</code>.</p>
</li>
</ul>
<h2 id="heading-step-3-implementing-tier3-automatic-fallbacks">Step 3: Implementing Tier3 – Automatic Fallbacks</h2>
<p>Even the best AI providers experience downtime, rate limits, or unexpected timeouts. A production-ready app can't just throw an error screen at the user when this happens. We need an execution engine that attempts to call the primary model provider and automatically catches errors. If anything goes wrong, it instantly pivots to the secondary fallback model without breaking the workflow .</p>
<p>Add the execution engine code to the script:</p>
<pre><code class="language-python">import os
import time

from anthropic import Anthropic, APIError as AnthropicAPIError
from dotenv import load_dotenv
from openai import OpenAI, APIError as OpenAIAPIError

load_dotenv()


class ResilientModelEngine:
    def __init__(self):
        self.openai_client = OpenAI(
            api_key=os.getenv("OPENAI_API_KEY", "dummy")
        )
        self.anthropic_client = Anthropic(
            api_key=os.getenv("ANTHROPIC_API_KEY", "dummy")
        )

    def _call_openai(self, model: str, prompt: str) -&gt; str:
        response = self.openai_client.chat.completions.create(
            model=model,
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                }
            ],
            timeout=10.0,
        )
        return response.choices[0].message.content

    def _call_anthropic(self, model: str, prompt: str) -&gt; str:
        response = self.anthropic_client.messages.create(
            model=model,
            max_tokens=1024,
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                }
            ],
            timeout=10.0,
        )
        return response.content[0].text

    def execute_provider_call(
        self,
        config: ModelConfig,
        prompt: str,
    ) -&gt; str:
        """
        Dispatches prompt execution to the correct provider SDK.
        """
        if config.provider == "openai":
            return self._call_openai(config.model_name, prompt)
        elif config.provider == "anthropic":
            return self._call_anthropic(config.model_name, prompt)
        else:
            raise ValueError(
                f"Unsupported provider: {config.provider}"
            )

    def execute_with_fallback(
        self,
        primary: ModelConfig,
        fallback: ModelConfig,
        prompt: str,
    ) -&gt; tuple[str, str]:
        """
        Attempts execution on the primary model and switches to the
        fallback model if the primary provider fails.

        Returns:
            tuple[str, str]: (Response text, Model used)
        """
        try:
            print(
                f"[Attempt] Calling Primary Provider: "
                f"{primary.provider} ({primary.model_name})"
            )

            result = self.execute_provider_call(primary, prompt)

            return result, (
                f"{primary.provider}:{primary.model_name}"
            )

        except (
            OpenAIAPIError,
            AnthropicAPIError,
            Exception,
        ) as e:
            print(f"[WARNING] Primary call failed due to: {e}")

            print(
                f"[Fallback] Switching to Secondary Provider: "
                f"{fallback.provider} ({fallback.model_name})"
            )

            try:
                result = self.execute_provider_call(
                    fallback,
                    prompt,
                )

                return result, (
                    f"{fallback.provider}:"
                    f"{fallback.model_name} (Fallback)"
                )

            except Exception as fallback_error:
                raise RuntimeError(
                    "Both primary and fallback systems failed. "
                    f"Error: {fallback_error}"
                )
</code></pre>
<h3 id="heading-breaking-down-the-code-logic-for-tier-3">Breaking Down the Code Logic for Tier 3</h3>
<p>Provider Clients (<code>_call_openai</code> &amp; <code>_call_anthropic</code>): Helper methods wrap provider SDK calls, establishing a unified strict 10-second timeout. If an API hangs, it aborts fast so the fallback can kick in without making the user wait.</p>
<p><code>execute_provider_call</code> Dispatcher: Acts as an abstraction bridge, matching the requested provider string to its respective API method.</p>
<p><code>execute_with_fallback</code> Resiliency Logic: Executes the primary provider first inside a try block. Catches API errors, rate limits, or network timeouts via provider-specific exceptions (OpenAIAPIError, AnthropicAPIError). Logically redirects execution to the fallback provider inside the except block. Only raises an unrecoverable <code>RuntimeError</code> if both primary and fallback providers fail. If your primary provider encounters issues, your console tracks the recovery process transparently:</p>
<p>[Attempt] Calling Primary Provider: anthropic (claude-3-5-sonnet-20241022)</p>
<p>[WARNING] Primary call failed due to: Connection timeout</p>
<p>[Fallback] Switching to Secondary Provider: <code>openai</code> (gpt-4o)</p>
<h3 id="heading-combining-the-architecture-into-a-unified-execution-pipeline">Combining the Architecture into a Unified Execution Pipeline</h3>
<p>Now you can combine all three layers into a unified pipeline.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a36420b52a5a7620def7e19/9070c55d-5c7f-4ab4-b951-9ae6175fed35.png" alt="Unified pipeline for executing AI tasks across multiple models and workflows." style="display:block;margin:0 auto" width="940" height="313" loading="lazy">

<p>Complete your <code>app.py</code> script with this orchestration class:</p>
<pre><code class="language-python">class SmartAIEngine:
    def __init__(self):
        self.analyzer = PromptAnalyzer()
        self.router = ModelRouter()
        self.executor = ResilientModelEngine()

    def process_request(self, user_prompt: str) -&gt; dict:
        print("\n==========================================")
        print("Processing New Request")
        print("==========================================")

        # Step 1: Analyze prompt complexity
        complexity = self.analyzer.analyze_complexity(
            user_prompt
        )
        print(
            f"[Step 1] Prompt classified as: "
            f"{complexity.value.upper()}"
        )

        # Step 2: Determine routing target
        primary_model, fallback_model = (
            self.router.get_models_for_tier(
                complexity
            )
        )

        print(
            f"[Step 2] Selected Primary: "
            f"{primary_model.model_name}"
        )

        # Step 3: Execute request with resilient fallbacks
        response_text, executed_model = (
            self.executor.execute_with_fallback(
                primary=primary_model,
                fallback=fallback_model,
                prompt=user_prompt,
            )
        )

        return {
            "status": "success",
            "complexity_tier": complexity.value,
            "model_used": executed_model,
            "response": response_text,
        }


# Execution Pipeline Test
if __name__ == "__main__":
    engine = SmartAIEngine()

    # Query 1: Simple task
    simple_query = (
        "What is the capital of Japan? "
        "Answer in one word."
    )

    result_1 = engine.process_request(
        simple_query
    )

    print(f"Model Used: {result_1['model_used']}")
    print(f"Response: {result_1['response']}")

    # Query 2: Complex task
    complex_query = (
        "Write a Python function to debug a "
        "memory leak in a multithreaded "
        "application."
    )

    result_2 = engine.process_request(
        complex_query
    )

    print(f"Model Used: {result_2['model_used']}")
    print(
        f"Response Snippet: "
        f"{result_2['response'][:100]}..."
    )
</code></pre>
<h3 id="heading-breaking-down-the-code-logic">Breaking Down the Code Logic</h3>
<ul>
<li><p>Unified Orchestration (<code>SmartAIEngine</code>): Initializes all three modular components—<code>PromptAnalyzer</code>, <code>ModelRouter</code>, and <code>ResilientModelEngine</code>—as instance properties.</p>
</li>
<li><p>The Pipeline Steps:</p>
<ul>
<li><p>Analyze: Evaluates the prompt string offline to get the complexity tier.</p>
</li>
<li><p>Route: Resolves primary and secondary model pairs based on that tier.</p>
</li>
<li><p>Execute: Calls the models resiliently and catches failure scenarios.</p>
</li>
</ul>
</li>
<li><p>Normalized Response Payload: Wraps execution details into a consistent output dictionary, keeping track of model usage, complexity categorization, and output text.</p>
</li>
</ul>
<h2 id="heading-lessons-learnt-from-dynamic-model-switching-in-production">Lessons Learnt from Dynamic Model Switching in Production</h2>
<p>Building a dynamic AI routing system taught our team critical lessons about enterprise LLM architectures:</p>
<p>First, keep classification light. Never use a large LLM call to classify prompts for small tasks. Use regex, keyword matching, and token-length rules. Your classifier should run in under 5 milliseconds.</p>
<p>Second, normalize system outputs. Different model providers structure outputs differently. Make sure your application wraps responses in a consistent schema before returning data to the user interface.</p>
<p>Third, set a tight timeout. Provider APIs often hang instead of throwing immediate errors. Set tight request timeouts (5 to 10 seconds) on your primary model calls so your fallback triggers quickly without frustrating the end user.</p>
<p>And finally, track usage metrics. Log every routing decision, model fallback, and cost delta. This data will reveal whether your complexity thresholds are properly tuned over time.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>As AI applications scale, relying on a single, monolithic LLM becomes unsustainable. Intelligent model routing allows you to balance performance, latency, and cost without sacrificing response quality.</p>
<p>By decoupling your application from specific model providers and introducing automated routing layers, input evaluation, provider abstraction, and resilient fallbacks, you can build production AI systems that are cost-effective, fast, and resilient.</p>
<p>As you deploy your own applications, treat LLM providers as dynamic utilities. Use lightweight models for everyday processing, reserve flagship models for complex tasks, and handle provider transitions cleanly in code.</p>
<h3 id="heading-thank-you-for-reading">Thank You for Reading!</h3>
<p>I hope this article has given you a practical understanding of how multi-model orchestrators and dynamic routing work in real-world applications and how you can begin implementing them in your own projects.</p>
<p>If you'd like to discuss AI engineering, Agentic AI, LLMs, RAG, MLOps, enterprise AI architecture, or AI governance, feel free to follow, like, share, and connect with me:</p>
<ul>
<li><p><a href="https://www.linkedin.com/in/chidiebere-njoku-921579142/">LinkedIn</a></p>
</li>
<li><p><a href="https://github.com/ChidiebereNjoku?tab=repositories">Explore my Github repositories</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Make Your Antigravity Agent Skills Configurable (Without Forking Them) ]]>
                </title>
                <description>
                    <![CDATA[ Antigravity Agent Skills are a great way to teach your AI agent a workflow once and reuse it everywhere. You write a short SKILL.md file, drop it in a folder, and the agent picks it up whenever it's r ]]>
                </description>
                <link>https://www.freecodecamp.org/news/make-your-antigravity-agent-skills-configurable-without-forking-them/</link>
                <guid isPermaLink="false">6a69c58763daca7bbbf2320b</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google Antigravity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Obum ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 09:19:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/7edaf407-ce56-4eff-8b1c-5e1c31e71067.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Antigravity Agent Skills are a great way to teach your AI agent a workflow once and reuse it everywhere. You write a short <code>SKILL.md</code> file, drop it in a folder, and the agent picks it up whenever it's relevant.</p>
<p>But these skills have a hidden limitation: they're static. If you download a skill someone else wrote and you want it to behave a little differently, you'll have to copy the whole thing and edit it by hand. And as you may have noticed lately, there are many "skills" forks floating around that are difficult to maintain.</p>
<p>In this tutorial, I'll show you I built a small convention that fixes this. It lets any Agent Skill read a per-project config file, so you can adopt any skill and customize how it behaves by editing a few lines of YAML (without ever touching the skill itself).</p>
<p>You'll build it step by step, test it, and see how to share it so other people can plug into it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-are-antigravity-agent-skills">What Are Antigravity Agent Skills</a>?</p>
</li>
<li><p><a href="#heading-why-static-skills-are-a-problem">Why Static Skills Are a Problem</a></p>
</li>
<li><p><a href="#heading-the-configurable-skills-solution">The Configurable Skills Solution</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-config-loader">How to Build the Config Loader</a></p>
</li>
<li><p><a href="#heading-how-to-make-a-skill-configurable">How to Make a Skill Configurable</a></p>
</li>
<li><p><a href="#heading-how-to-add-project-overrides">How to Add Project Overrides</a></p>
</li>
<li><p><a href="#heading-how-to-test-your-configurable-skill">How to Test Your Configurable Skill</a></p>
</li>
<li><p><a href="#heading-two-more-example-skills">Two More Example Skills</a></p>
</li>
<li><p><a href="#heading-how-to-share-your-agent-skills-with-others">How to Share Your Agent Skills With Others</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>You will build a tiny, reusable layer called <strong>Configurable Agent Skills</strong>. It has three parts:</p>
<ol>
<li><p>A small Python script, <code>resolve_config.py</code>, that merges a skill's default settings with your project settings and prints the result.</p>
</li>
<li><p>A convention: each skill ships 2 files, a <code>config.default.yaml</code> file with its "knobs" and a <code>SKILL.md</code> file. They both guide the agent's behavior.</p>
</li>
<li><p>A per-project file, <code>.agent/skills.config.yaml</code>, where anyone using your skill sets their own values.</p>
</li>
</ol>
<p>By the end, you'll have a working <code>git-commit-formatter</code> skill that one team can run in Conventional Commits mode and another team can switch to gitmoji mode, all using the exact same skill files with no forking.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you'll need:</p>
<ul>
<li><p>Google Antigravity installed (the IDE, CLI, or SDK. Any of them work, since skills are just files.).</p>
</li>
<li><p>Python 3 installed, with PyYAML. You can install PyYAML with <code>python -m pip install pyyaml</code>.</p>
</li>
<li><p>Basic comfort with the terminal and YAML. You don't need to be an expert in either.</p>
</li>
</ul>
<p>If you've never written an Agent Skill before, the next two sections will bring you up to speed.</p>
<h2 id="heading-what-are-antigravity-agent-skills">What Are Antigravity Agent Skills?</h2>
<p>A Skill in Antigravity is a folder that contains a <code>SKILL.md</code> file and, optionally, some scripts, templates, or examples. The <code>SKILL.md</code> file has a short block of YAML "frontmatter" at the top (a <code>name</code> and a <code>description</code>), followed by a set of instructions written in plain Markdown.</p>
<p>Here's the important part: skills are loaded on demand. The agent reads only the short <code>description</code> of each skill at first. When your request matches that description, the agent pulls in the full instructions and follows them. This keeps the agent's context small and focused.</p>
<p>A minimal skill that enforces Conventional Commits looks like this:</p>
<pre><code class="language-markdown">---
name: git-commit-formatter
description: Formats git commit messages using the Conventional Commits specification. Use this when the user asks to commit changes or write a commit message.
---

# Git Commit Formatter

When writing a commit message, follow the Conventional Commits format:
`type(scope): description`

Allowed types: feat, fix, docs, style, refactor, perf, test, chore.
</code></pre>
<p>Drop that in your skills folder, ask the agent to "commit these changes," and it will write a properly formatted message. Simple and useful, right?</p>
<h2 id="heading-why-static-skills-are-a-problem">Why Static Skills Are a Problem</h2>
<p>Now look closely at that skill. The allowed types (<code>feat</code>, <code>fix</code>, <code>docs</code>, and so on) are baked directly into the instructions.</p>
<p>That's fine until someone wants something slightly different. Maybe your team also uses a <code>ci</code> type. Maybe you prefer gitmoji, where each commit starts with an emoji. Maybe you want to require a scope on every commit.</p>
<p>With a static skill, there's only one way to get any of that: copy the whole skill and edit the Markdown. When you do this across a team, everyone ends up with their own private fork. When the original author ships an improvement, none of the forks get it. The skill stops being something you <em>share</em> and becomes something everyone <em>rewrites</em>.</p>
<p>The core issue is that there's no clean line between the skill's logic (which everyone should share) and its settings (which each project wants to control). How do we solve this?</p>
<h2 id="heading-the-configurable-skills-solution">The Configurable Skills Solution</h2>
<p>The idea is simple. Instead of hard-coding settings in the instructions, the skill will:</p>
<ol>
<li><p>Ship its settings and their defaults in a separate <code>config.default.yaml</code> file.</p>
</li>
<li><p>Read a merged config (defaults plus any project-level overrides) before it acts.</p>
</li>
</ol>
<p>The project-level overrides live in a file called <code>.agent/skills.config.yaml</code>, which sits at the root of the user's project:</p>
<pre><code class="language-yaml"># .agent/skills.config.yaml 
# (edit this file in your project instead of the skill globally)
git-commit-formatter:
  style: gitmoji
  extra_types: [ci, build]
  scope_required: true
</code></pre>
<p>That's the easy flow. Drop the skill in, set a few keys, and you're done. The skill's own files never change.</p>
<p>To make this work, you need a script that reads both files, merges them, and hands the result to the agent. Let's build it.</p>
<h2 id="heading-how-to-build-the-config-loader">How to Build the Config Loader</h2>
<p>Create a file called <code>resolve_config.py</code>. Its job is to take a skill's name, load that skill's <code>config.default.yaml</code>, find the user's <code>.agent/skills.config.yaml</code>, and merge the two so that user values win.</p>
<p>Start with a deep-merge helper. This is the heart of the loader:</p>
<pre><code class="language-python">def deep_merge(base, override):
    """Recursively merge override onto base.

    Dicts merge key by key. Anything else (scalars, lists) is replaced
    wholesale by the override value.
    """
    if isinstance(base, dict) and isinstance(override, dict):
        merged = dict(base)
        for key, value in override.items():
            merged[key] = deep_merge(merged[key], value) if key in merged else value
        return merged
    return override
</code></pre>
<p>Notice the deliberate choice here: dictionaries merge key by key, but lists are replaced, not appended. That keeps the behavior predictable. If you want to handle "defaults plus extras", use the explicit <code>extra_types</code> key in the skill as you'll see in the example below.</p>
<p>Next, you need to find your "per-project" config. The loader walks up from the current directory looking for an <code>.agent/skills.config.yaml</code> file:</p>
<pre><code class="language-python">from pathlib import Path

def find_project_config(start: Path):
    """Walk upward from start looking for .agent/skills.config.yaml."""
    start = start.resolve()
    for folder in [start, *start.parents]:
        candidate = folder / ".agent" / "skills.config.yaml"
        if candidate.is_file():
            return candidate
    return None
</code></pre>
<p>Now put it together. The loader locates the skill's defaults (which sit next to the script), loads your overrides for that skill's name, merges them, and prints the result:</p>
<pre><code class="language-python">import sys, yaml
from pathlib import Path

def resolve(skill_name, skill_dir, project_root):
    defaults = yaml.safe_load((Path(skill_dir) / "config.default.yaml").read_text()) or {}

    user_path = find_project_config(Path(project_root))
    user_all = yaml.safe_load(user_path.read_text()) if user_path else {}
    user_cfg = (user_all or {}).get(skill_name, {}) or {}

    return deep_merge(defaults, user_cfg)
</code></pre>
<p>That completes the whole idea. The full version in the sample repo adds a command-line interface, JSON output, and clear error messages, but the logic above is all you really need.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f92a5e56aa1ed54804bb866/ca8d7095-21f6-4433-8c82-b6b0ca34b9ef.png" alt="Terminal output showing the resolved configuration for the git-commit-formatter skill." style="display:block;margin:0 auto" width="1380" height="900" loading="lazy">

<h2 id="heading-how-to-make-a-skill-configurable">How to Make a Skill Configurable</h2>
<p>Now you'll convert the static commit skill into a configurable one. This takes two files.</p>
<p>First, create <code>config.default.yaml</code> next to the skill. It lists every setting and a safe default, so the skill works even when the user has no config at all:</p>
<pre><code class="language-yaml"># Default configuration for the git-commit-formatter skill.
style: conventional          # conventional | gitmoji
types:                       # base set of allowed commit types
  - feat
  - fix
  - docs
  - style
  - refactor
  - perf
  - test
  - chore
extra_types: []              # additional types, merged on top of `types`
scope_required: false        # if true, require a scope: type(scope): ...
max_subject_length: 72       # hard cap on the subject line
</code></pre>
<p>Second, update <code>SKILL.md</code> so that its very first instruction is to resolve the config and apply it. This is the key move: you're telling the agent to read the settings before it does anything else:</p>
<pre><code class="language-markdown">---
name: git-commit-formatter
description: Formats git commit messages to a team's chosen convention (Conventional Commits or gitmoji). Use this when the user asks to commit changes or write a commit message. Reads per-project settings so teams customize commit style without editing this skill.
---

# Git Commit Formatter (Configurable)

## Step 1 - Resolve configuration (always do this first)

Run the loader and read its output:

`python scripts/resolve_config.py git-commit-formatter --project-root .`

Apply exactly those settings:

- `style`: `conventional` or `gitmoji`.
- `types` + `extra_types`: the full set of allowed commit types.
- `scope_required`: if true, a scope is mandatory.
- `max_subject_length`: hard cap on the subject line.

## Step 2 - Compose the message

Pick the primary type from `types` + `extra_types`, build the subject in the
chosen `style`, and enforce `scope_required` and `max_subject_length`.
</code></pre>
<p>This pattern ("make the agent run a script and obey its output") is the same one Antigravity's own validation skills use. It keeps the behavior deterministic instead of leaving it to the model's memory.</p>
<p>Notice how <code>extra_types</code> solves the additive-list question. The default list stays put, and the user's extras are simply added on top by the skill. No fork is required to add a <code>ci</code> type.</p>
<h2 id="heading-how-to-add-project-overrides">How to Add Project Overrides</h2>
<p>Let's say you want gitmoji commits with two extra types. Create a single file in your project:</p>
<pre><code class="language-yaml"># .agent/skills.config.yaml
git-commit-formatter:
  style: gitmoji
  extra_types: [ci, build]
  scope_required: true
</code></pre>
<p>You just changed three lines of config and didn't open the skill or fork any code. The next time the agent commits, it will use this project settings.</p>
<p>And a different project, with no config file at all, keeps getting the sensible Conventional Commits defaults. You have one skill with many behaviors.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f92a5e56aa1ed54804bb866/c9cb7c61-4a6f-4963-94ed-321bb20bde30.png" alt="The agent proposing a commit message that starts with an emoji, driven by the project config.&quot;" style="display:block;margin:0 auto" width="1380" height="740" loading="lazy">

<h2 id="heading-how-to-test-your-configurable-skill">How to Test Your Configurable Skill</h2>
<p>You don't need the agent to check that the merge works. Run the loader directly and read the output.</p>
<p>With no overrides, you get the defaults:</p>
<pre><code class="language-bash">$ python scripts/resolve_config.py git-commit-formatter --project-root .
style: conventional
scope_required: false
...
</code></pre>
<p>Now add the <code>.agent/skills.config.yaml</code> override from the last section and run it again:</p>
<pre><code class="language-bash">$ python scripts/resolve_config.py git-commit-formatter --project-root . --print-sources
style: gitmoji
scope_required: true
extra_types:
- ci
- build
types:
- feat
- fix
- docs
...
</code></pre>
<p>The <code>style</code> flipped to <code>gitmoji</code>, <code>scope_required</code> became <code>true</code>, and your extra types appeared (while the base <code>types</code> list stayed intact). That confirms the merge does exactly what you want.</p>
<p>It's worth writing a small automated test too, so a future change to the loader can't silently break the merge. A test can create a fake skill and a fake project config in a temp folder, run the loader, and assert that user values override defaults while untouched defaults survive.</p>
<h2 id="heading-two-more-example-skills">Two More Example Skills</h2>
<p>The same pattern works for any skill. Here are two more to show the range.</p>
<h3 id="heading-a-changelog-generator">A Changelog Generator</h3>
<p>Its <code>config.default.yaml</code> exposes the output <code>format</code> (like Keep a Changelog), which commit <code>types</code> to include, and whether to link commit hashes to a repo URL. One project can generate a formal changelog grouped by type, while another can generate a simple bulleted list. It's the same skill with a different config.</p>
<pre><code class="language-yaml"># changelog-generator config.default.yaml (excerpt)
format: keepachangelog       # keepachangelog | conventional | simple
include_types: [feat, fix, perf]
include_authors: false
repo_url: ""                 # if set, hashes link to commits
</code></pre>
<h3 id="heading-a-license-header-adder">A License-Header Adder</h3>
<p>Its config exposes the <code>license</code> (Apache-2.0, MIT, or custom), the <code>holder</code>, and a map of file extensions to comment styles. A company sets the holder once in their project config, and every new file gets the right header in the right comment style, without editing the skill.</p>
<pre><code class="language-yaml"># license-header-adder config.default.yaml (excerpt)
license: apache-2.0          # apache-2.0 | mit | custom
holder: "Your Name or Org"
year: auto                   # auto = current year
</code></pre>
<p>The lesson is that almost any skill has a few decisions baked into it. When you pull those decisions into a <code>config.default.yaml</code>, you convert a one-off skill into a tool that anyone can reuse and tune.</p>
<h2 id="heading-how-to-share-your-agent-skills-with-others">How to Share Your Agent Skills With Others</h2>
<p>Once your agent skills follow the convention, they compose into something bigger. To make your agent skills easy for others to adopt, you have to:</p>
<ul>
<li><p><strong>Keep each skill self-contained:</strong> Vendor a copy of <code>resolve_config.py</code> inside each skill's <code>scripts/</code> folder, so someone can copy a single skill folder anywhere and it just works.</p>
</li>
<li><p><strong>Document every config key</strong> in the <code>SKILL.md</code>, so users know exactly what they can tune.</p>
</li>
<li><p><strong>Publish a small index:</strong> A simple <code>index.json</code> that lists each skill's name, path, and config keys makes it easy for others to discover what you've built and contribute their own.</p>
</li>
</ul>
<p>Because the convention is just "read a config file first," anyone can publish a compatible skill. Each new configurable skill makes the whole ecosystem more useful. In addition to shipping a skill, you're shipping a small standard that other people can build on.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>You started with a static skill whose behavior was frozen in Markdown, and you turned it into a configurable one that anyone can tune from a single project file.</p>
<p>The entire setup is relatively small. It has the merge function, one convention, and a <code>config.default.yaml</code> per skill.</p>
<p>It also changes how skills are shared. Instead of forking a skill to change one setting, you can keep the shared logic and adjust your own config. Improvements to the skill flow to everyone, and everyone still gets the behavior they want.</p>
<p>If you want to try it, build the <code>git-commit-formatter</code> skill from this tutorial, drop it into your Antigravity skills folder, and add an <code>.agent/skills.config.yaml</code> to a project. Then flip <code>style</code> from <code>conventional</code> to <code>gitmoji</code> and watch the same skill behave differently.</p>
<p>From there, make one of your own skills configurable. Find the settings you baked into the instructions, move them into a <code>config.default.yaml</code>, and let your users take it from there.</p>
<p>The full sample code (the loader, its tests, and all three example skills) is on GitHub at <a href="https://github.com/keepdeploying/configurable-agent-skills">github.com/keepdeploying/configurable-agent-skills</a>.</p>
<p>Thanks for reading. If you build a configurable skill of your own, share it. Let's keep the ecosystem growing.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
