<?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[ automation - freeCodeCamp.org ]]>
        </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[ automation - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 22 Aug 2026 15:53:36 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/automation/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ The ETL Pipeline Handbook: How to Build a Production-Grade Pipeline in Python ]]>
                </title>
                <description>
                    <![CDATA[ Tracking flood risk takes one unglamorous but essential thing: clean and structured data. In this tutorial, you'll build a data pipeline yourself. You'll create a Python ETL (Extract, Transform, Load) ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-etl-pipeline-handbook-how-to-build-a-production-grade-pipeline-in-python/</link>
                <guid isPermaLink="false">6a679921f4d9ad6845fede20</guid>
                
                    <category>
                        <![CDATA[ data-engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ETL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ python projects ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pandas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Pipeline ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ brooklyn ]]>
                </dc:creator>
                <pubDate>Mon, 27 Jul 2026 17:45:05 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/36906090-d056-4207-8632-fcdf35843018.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Tracking flood risk takes one unglamorous but essential thing: clean and structured data.</p>
<p>In this tutorial, you'll build a data pipeline yourself. You'll create a Python ETL (Extract, Transform, Load) pipeline that pulls daily water-level readings from <a href="https://hubeau.eaufrance.fr/">Hub'Eau</a>, France's official open water-data API. Then, you'll clean that data and publish it as a public dataset, just like the <a href="https://www.kaggle.com/code/grimespoint/paris-flood-dataset-weekly-updater">live version</a> does.</p>
<p>This tutorial is based on a real pipeline that runs once a week, on a schedule, and it keeps the <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">Paris Flood Dataset</a> updated automatically.</p>
<p>You won't just copy and paste code, though. The real goal is to understand <em>why</em> the pipeline works the way it does. You'll walk through the design decisions that separate a script meant to run once from a script that keeps working, unattended, for years.</p>
<p>You can code along with this <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">notebook</a>. For most of the tutorial, the pipeline runs on <strong>simulated (mock) API data</strong>. This lets you run every cell safely, without hammering a real server. A later section shows how to switch to the live API.</p>
<p>By the end, you'll be able to:</p>
<ul>
<li><p>Explain and implement the Extract, Transform, Load pattern</p>
</li>
<li><p>Manage configuration with Python <code>@dataclass</code> instead of scattering constants everywhere</p>
</li>
<li><p>Write API-fetching code that survives network failures and paginated responses</p>
</li>
<li><p>Apply robust type-coercion so one bad row can't crash a whole pipeline run</p>
</li>
<li><p>Deduplicate and merge incremental data safely</p>
</li>
<li><p>Wire everything into a single, idempotent, schedulable <code>main()</code> entry point</p>
</li>
</ul>
<h3 id="heading-table-of-contents">Table of Contents:</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-part-1-the-design-logic">Part 1: The Design Logic</a></p>
<ul>
<li><p><a href="#heading-what-is-an-etl-pipeline">What is an ETL pipeline?</a></p>
</li>
<li><p><a href="#heading-the-architecture-at-a-glance">The architecture, at a glance</a></p>
</li>
<li><p><a href="#heading-two-patterns-that-make-it-production-grade">Two patterns that make it production-grade</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-setup-and-dependencies">Part 2: Setup and Dependencies</a></p>
</li>
<li><p><a href="#heading-part-3-manage-configuration-with-dataclasses">Part 3: Manage Configuration with Dataclasses</a></p>
<ul>
<li><p><a href="#heading-why-bother-with-a-config-layer-at-all">Why bother with a config layer at all?</a></p>
</li>
<li><p><a href="#heading-code-level-walkthrough">Code-level walkthrough</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-the-extraction-step">Part 4: The Extraction Step</a></p>
<ul>
<li><p><a href="#heading-graceful-file-loading">Graceful file loading</a></p>
</li>
<li><p><a href="#heading-incremental-update-logic">Incremental update logic</a></p>
</li>
<li><p><a href="#heading-simulate-the-api">Simulate the API</a></p>
</li>
<li><p><a href="#heading-fetch-data-for-real">Fetch data for real</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-5-the-transform-step">Part 5: The Transform Step</a></p>
<ul>
<li><p><a href="#heading-type-parsing-and-graceful-coercion">Type parsing and graceful coercion</a></p>
</li>
<li><p><a href="#heading-schema-translation-with-bidirectional-mappings">Schema translation with bidirectional mappings</a></p>
</li>
<li><p><a href="#heading-compute-flood-alerts">Compute flood alerts</a></p>
</li>
<li><p><a href="#heading-column-ordering">Column ordering</a></p>
</li>
<li><p><a href="#heading-deduplication">Deduplication</a></p>
</li>
<li><p><a href="#heading-put-it-all-together-in-postprocess">Put it all together inpostprocess()</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-6-the-load-step">Part 6: The Load Step</a></p>
<ul>
<li><p><a href="#heading-design-logic">Design logic</a></p>
</li>
<li><p><a href="#heading-code-level-walkthrough">Code-level walkthrough</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-7-assemble-the-full-pipeline">Part 7: Assemble the Full Pipeline</a></p>
<ul>
<li><p><a href="#heading-the-global-rehearsal-mock-mode">The global rehearsal (mock mode)</a></p>
</li>
<li><p><a href="#heading-main-pipeline-orchestration">main(): pipeline orchestration</a></p>
</li>
<li><p><a href="#heading-the-if-name-main-guard">Theif name == "main":guard</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-8-go-live-and-switch-to-the-real-api">Part 8: Go Live and Switch to the Real API</a></p>
<ul>
<li><a href="#heading-post-run-validation">Post-run validation</a></li>
</ul>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p><strong>Python 3.10+</strong>. The code uses type hints and dataclasses. These work from Python 3.7 onward, but 3.10+ is best.</p>
</li>
<li><p><a href="https://leetcode.com/studyplan/introduction-to-pandas/"><strong>Knowledge of pandas DataFrames</strong></a>: read data, filter rows, and basic column operations.</p>
</li>
<li><p>Comfort with <strong>functions and basic OOP</strong> (<a href="https://realpython.com/python3-object-oriented-programming/">Object-Oriented programming</a>) in Python. Don't worry, this guide explains every non-obvious piece as you go.</p>
</li>
<li><p>Optional: a free <a href="https://www.kaggle.com/">Kaggle</a> account and the <a href="https://github.com/Kaggle/kaggle-api">Kaggle CLI</a>, only if you want to run the final publishing step for real.</p>
</li>
</ul>
<p>Install the dependencies:</p>
<pre><code class="language-bash">pip install requests pandas numpy ipykernel
</code></pre>
<p>You'll do the work inside a <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">Jupyter notebook</a>. Download the <code>.ipynb</code> file from Kaggle, or click "Copy and Edit" to work directly on Kaggle. You'll need a Kaggle account for that.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/681c54fc-c12a-4c01-83ba-17368d4ccc49.png" alt="The menu button that provides options to download the data engineering follow along notebook on Kaggle." style="display:block;margin:0 auto" width="695" height="502" loading="lazy">

<p><strong>Optional:</strong> If you plan to run the notebook locally, install the <code>notebook</code> package too:</p>
<pre><code class="language-bash">pip install notebook
</code></pre>
<h2 id="heading-part-1-the-design-logic">Part 1: The Design Logic</h2>
<p>Before you touch a single line of code, we'll spend three minutes on <em>why</em> the pipeline is shaped this way.</p>
<p>This is the <strong>big-picture view</strong>. Every code-level decision later traces back to one of these ideas. Read this section even if you skim everything else.</p>
<h3 id="heading-what-is-an-etl-pipeline">What is an ETL Pipeline?</h3>
<p>ETL stands for <strong>Extract, Transform, Load</strong>. It's the standard pattern for moving data from a source to a destination in a reliable, repeatable way.</p>
<ul>
<li><p><strong>Extract</strong>: pull data from a source, like an API, a database, or files.</p>
</li>
<li><p><strong>Transform</strong>: clean, standardize, enrich, and validate the data.</p>
</li>
<li><p><strong>Load</strong>: write the result to a destination, like a warehouse, a CSV, or a public platform.</p>
</li>
</ul>
<p>Here's how those three stages map onto this project:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>What happens here</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Extract</strong></td>
<td>Load existing data if it exists, then call the Hub'Eau API (via <code>requests</code>) for each gauging station</td>
</tr>
<tr>
<td><strong>Transform</strong></td>
<td>Translate French columns and entries to English, fix data types, remove duplicates</td>
</tr>
<tr>
<td><strong>Load</strong></td>
<td>Write a CSV and a metadata file, then publish to Kaggle via the CLI</td>
</tr>
</tbody></table>
<h3 id="heading-the-architecture-at-a-glance">The Architecture, at a Glance</h3>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/6bede708-bdb9-4cae-bfd8-1dc1aa0348f6.png" alt="Schema of a simple ETL pipeline." style="display:block;margin:0 auto" width="2040" height="900" loading="lazy">

<p>Notice that "Extract" already touches two different sources: the <em>existing</em> dataset (what you already have) and the <em>new</em> data from the API. That distinction is the seed of the next idea.</p>
<h3 id="heading-two-patterns-that-make-it-production-grade">Two Patterns that Make it Production-Grade</h3>
<p>There two patterns that make this pipeline safe to run unattended, scheduled, for years.</p>
<h4 id="heading-1-idempotency">1. Idempotency</h4>
<p><a href="https://en.wikipedia.org/wiki/Idempotence"><strong>Idempotence</strong></a> means when the same operation runs twice it gives the same result as if it runs once. Deduplication is what makes this pipeline idempotent. If the scheduler accidentally triggers twice, or a network retry fetches the same day again, the second run won't create duplicate rows. This matters a lot for anything that runs on a schedule with no one watching it.</p>
<h4 id="heading-2-incremental-loading">2. Incremental loading</h4>
<p>A naïve pipeline would re-download the <em>entire</em> history on every run. That's slow, it wastes your API quota, and it's fragile: the more data you transfer, the more chances something fails.</p>
<p>An <strong>incremental</strong> pipeline avoids this. Instead, it:</p>
<ul>
<li><p>Checks the most recent date already in the dataset</p>
</li>
<li><p>Requests only the data <em>from that date onward</em></p>
</li>
<li><p>Merges the new records into the existing dataset</p>
</li>
</ul>
<p>You'll find this logic built explicitly in <a href="#heading-incremental-update-logic"><code>determine_update_range</code></a>.</p>
<p>Always ask yourself: <strong>"Do I actually need to do this?"</strong> That one habit separates a fragile script from a production pipeline. For example, every "expensive" or "external" step in this pipeline (network calls, disk writes, publishing) is guarded by a cheap, local check first.</p>
<h2 id="heading-part-2-setup-and-dependencies">Part 2: Setup and Dependencies</h2>
<p>Here's the import block. It looks unremarkable, but how it's organized is itself a best practice worth calling out.</p>
<pre><code class="language-python"># Standard library imports
import json
import os
import random
import subprocess
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

# Third-party libraries
import numpy as np
import pandas as pd
import requests

# Display config for notebooks
pd.set_option('display.max_columns', None)   # All columns will show
pd.set_option('display.max_colwidth', None)  # Prevents cutting long column text with ...
</code></pre>
<p><strong>Code-level notes:</strong></p>
<ul>
<li><p>Imports fall into three blocks: <strong>standard library, then third-party, then local</strong>, with blank lines between them. This follows <a href="https://peps.python.org/pep-0008/#imports">PEP 8's import-ordering convention</a>. Most <a href="https://en.wikipedia.org/wiki/Pretty-printing">auto-formatters</a> (<code>isort</code>, <code>ruff</code>) enforce this same grouping.</p>
</li>
<li><p>Nothing is imported with <code>from module import *</code>. That syntax pollutes the current namespace. It makes it hard to trace where a name came from when someone debugs the code six months later. Python style guides echo this advice, including <a href="https://google.github.io/styleguide/pyguide.html">Google's Python Style Guide</a>.</p>
</li>
<li><p>The <code>pd.set_option(...)</code> calls exist purely for <em>notebook readability</em>, so wide DataFrames don't get truncated with <code>...</code>. They have zero effect on the pipeline's logic. You'd typically remove or scope them differently in a <code>.py</code> script.</p>
</li>
</ul>
<h2 id="heading-part-3-manage-configuration-with-dataclasses">Part 3: Manage Configuration with Dataclasses</h2>
<h3 id="heading-why-bother-with-a-config-layer-at-all">Why Bother with a Config Layer at All?</h3>
<p>Every pipeline has <strong>knobs</strong>: which stations to monitor, what the flood threshold is, and where to publish. The bad approach sprinkles these values as literals throughout the code as you write it. You end up with an <code>if level &gt; 6000:</code> buried three functions deep. Then changing <em>any</em> setting means hunting through the whole file, and it's easy to update one spot and miss another.</p>
<p>The fix: <strong>centralize all settings in one place.</strong> Python's <a href="https://docs.python.org/3/library/dataclasses.html"><code>@dataclass</code></a> decorator is a natural fit for that.</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Why it matters</th>
</tr>
</thead>
<tbody><tr>
<td>Auto-generated <code>__init__</code>, <code>__repr__</code>, <code>__eq__</code></td>
<td>You don't need to write them yourself</td>
</tr>
<tr>
<td><a href="https://www.geeksforgeeks.org/python/type-hints-in-python/">Type hints</a></td>
<td>Gives you IDE autocomplete and self-documenting code</td>
</tr>
<tr>
<td>Optional <code>frozen=True</code></td>
<td>Gives you <a href="https://stackoverflow.com/questions/66194804/what-does-frozen-mean-for-dataclasses">true immutability</a> if you want config knobs that can't change after creation</td>
</tr>
<tr>
<td><code>__post_init__</code> hook</td>
<td>Validates or computes derived fields once, right after construction</td>
</tr>
</tbody></table>
<p>Compare that to a configuration written as a plain <code>dict</code>:</p>
<pre><code class="language-python">config = {
    "stations": ["STN001", "STN002"],
    "flood_threshold": 6000,
    "publish_url": "https://example.com/alerts",
    "retry_count": 2,
    "timeout_seconds": 5,
}
</code></pre>
<p>A plain dict gives you none of that: no immutability, no type checking, and no autocomplete.</p>
<h3 id="heading-code-level-walkthrough">Code-Level Walkthrough</h3>
<p>The pipeline defines three configuration classes. Each one has <em>a single responsibility</em>: API details, station rules, and publishing destination. Each class becomes one <strong>module-level singleton instance</strong>. Every other function in the pipeline reads from these singletons.</p>
<pre><code class="language-python">@dataclass
class APIConfig:
    """API configuration for HubEau data fetching.

    Think of this as the "address book" for the API.
    """
    use_mock: bool = True
    base_url: str = "https://hubeau.eaufrance.fr/api/v2/hydrometrie/obs_elab"
    metric: str = "HIXnJ"  # Daily max water level (elaborated observations)
    # Pagination: fetch 20k records per request (API Limit)
    max_per_page: int = 20000
    timeout_seconds: int = 60  # Network timeout

    def __post_init__(self):
        """Validate configuration after initialization."""
        if self.max_per_page &lt;= 0:
            raise ValueError("max_per_page must be positive")
        if self.timeout_seconds &lt;= 0:
            raise ValueError("timeout_seconds must be positive")
</code></pre>
<p>Notice the validation inside <code>__post_init__</code>. It runs right after the auto-generated <code>__init__</code>. A misconfigured <code>APIConfig(max_per_page=-1)</code> fails loudly and immediately at startup, instead of surfacing as a bug later during an actual pipeline run.</p>
<pre><code class="language-python">@dataclass
class StationConfig:
    """Station monitoring configuration.

    The 'what' of data collection: which stations, what's a flood?
    """
    station_codes: List[str] = field(default_factory=lambda: [
        "F700000109", "F700000110", "F700000111",
        "F700000102", "F700000103",
    ])
    flood_threshold_mm: int = 6000  # Flood alert threshold
    earliest_date: str = "1900-01-01"  # How far back to go
</code></pre>
<p><strong>The mutable-default trap:</strong> Look closely at <code>station_codes</code>. It isn't written as <code>station_codes: List[str] = [...]</code>. That's deliberate, and it dodges one of the most common gotchas in Python. If you use a plain mutable object (a list, dict, or set) as a default argument or dataclass field, <strong>every instance shares the same underlying object</strong>. Mutate it on one instance, and you silently mutate it everywhere else too.</p>
<p>Stack Overflow covers this at length in its <a href="https://stackoverflow.com/questions/1132941/least-astonishment-and-the-mutable-default-argument">"least astonishment" mutable-default-argument discussion</a>, as does <a href="https://realpython.com/python-optional-arguments/">Real Python's guide to optional arguments</a>. The fix is <code>field(default_factory=...)</code>. It calls a <em>fresh</em> factory function (here, a <code>lambda</code>) for every new instance, so each one gets its own independent list.</p>
<p>Explanation:</p>
<pre><code class="language-python"># Bad: shared mutable default
@dataclass
class BadConfig:
    station_codes: list[str] = []

a = BadConfig()
b = BadConfig()

a.station_codes.append("ALERT")
print(a.station_codes)  # ['ALERT']
print(b.station_codes)  # ['ALERT']  &lt;-- same list!!


# Good: fresh list per instance
@dataclass
class GoodConfig:
    station_codes: list[str] = field(default_factory=list)

x = GoodConfig()
y = GoodConfig()

x.station_codes.append("ALERT")
print(x.station_codes)  # ['ALERT']
print(y.station_codes)  # []  &lt;-- independent list
</code></pre>
<p>And the last Config singleton, the Kaggle settings:</p>
<pre><code class="language-python">@dataclass
class KaggleConfig:
    """Kaggle dataset publishing configuration."""
    dataset_slug: str = "grimespoint/paris-flood-dataset"
    input_csv: str = "kaggle/input/datasets/{slug}/paris_flood_dataset.csv"
    output_dir: Path = field(default_factory=lambda: Path("kaggle/working/kaggle_dataset"))
    mock_output_dir: Path = field(default_factory=lambda: Path("mock_output"))
    output_filename: str = "paris_flood_dataset.csv"
    mock_output_filename: str = "mock_flood_dataset.csv"
    metadata_filename: str = "dataset-metadata.json"

    # Metadata
    title: str = "Paris flood dataset"
    keywords: list = field(default_factory=lambda: [
        "tabular", "weather and climate", "environment", "europe", "time series analysis"
    ])
    geospatial_coverage: str = "Paris, France"
    update_frequency: str = "Weekly"
    license_name: str = "CC0-1.0"

    # Computed fields (set in __post_init__)
    output_csv_path: Path = field(init=False)
    metadata_path: Path = field(init=False)

    def __post_init__(self):
        """Compute derived paths after initialization."""
        self.input_csv = self.input_csv.format(slug=self.dataset_slug)
        self.output_csv_path = self.output_dir / self.output_filename
        self.metadata_path = self.output_dir / self.metadata_filename
        self.mock_output_filename = self.mock_output_dir / self.mock_output_filename

# Initialize configs - module-level singletons
API_CONFIG = APIConfig()
STATION_CONFIG = StationConfig()
KAGGLE_CONFIG = KaggleConfig()
</code></pre>
<p>This is the most common use of <code>__post_init__</code>. <code>output_csv_path</code> and <code>metadata_path</code> are marked <code>field(init=False)</code>, so you can't set them directly through the constructor. Instead, <code>__post_init__</code> computes them from other fields (<code>output_dir</code> and <code>output_filename</code>).</p>
<p>Use this pattern for <strong>derived, computed values</strong>: compute them once, in one place, instead of recomputing <code>output_dir / output_filename</code> every time you need the path elsewhere in the codebase.</p>
<p>See <a href="https://realpython.com/python-data-classes/#comparing-cards">Real Python's data classes guide</a> or the data classes chapter of O'Reilly's <em>Fluent Python</em> for more on this pattern.</p>
<p><strong>Tip:</strong> the auto-generated <code>__repr__</code> gives you a readable printout for free. Call <code>print(API_CONFIG)</code>: it shows every field and value without a single line of formatting code. It's handy for quick sanity checks when you're debugging a pipeline run.</p>
<pre><code class="language-python">print(APIConfig)
# prints APIConfig(use_mock=True, base_url='https://hubeau.eaufrance.fr/api/v2/hydrometrie/obs_elab', ...)
</code></pre>
<h2 id="heading-part-4-the-extraction-step">Part 4: The Extraction Step</h2>
<p>The Extract phase pulls data from source systems and reads it into memory. Here, you genuinely have <strong>two</strong> sources to extract from: the <em>existing</em> dataset (what you already published last time) and the <em>new</em> (recent) data from the Hub'Eau API.</p>
<h3 id="heading-graceful-file-loading">Graceful File Loading</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>What should happen the very first time this pipeline runs (and there's no existing dataset yet)? A naïve implementation would crash with a <code>FileNotFoundError</code>.</p>
<p>Here's the trick: <code>load_csv()</code> follows the <a href="https://en.wikipedia.org/wiki/Null_object_pattern"><strong>Null Object pattern</strong></a>. Instead of raising an error, it returns an <em>empty</em> DataFrame. Every downstream function can then treat "no existing data" and "some existing data" the same way, with no special-casing needed.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p>This function loads a CSV file into a DataFrame. If the file doesn't exist, it returns an empty DataFrame instead of crashing.</p>
<p><code>low_memory=False</code> tells pandas to read the file carefully, so it avoids mixed-type guesses. <code>parse_dates=True</code> tries to automatically convert date-like columns into dates. <code>delimiter=","</code> tells pandas the file is comma-separated.</p>
<pre><code class="language-python">def load_csv(path: str) -&gt; pd.DataFrame:
    """Load CSV file or return empty DataFrame if file does not exist.

    Args:
        path (str): Full path to the CSV file.

    Returns:
        pd.DataFrame: Loaded data, or empty DataFrame if file not found.

    Raises:
        pd.errors.ParserError: If the CSV is malformed.
    """
    if os.path.exists(path):
        return pd.read_csv(path, low_memory=False, parse_dates=True, delimiter=",")
    return pd.DataFrame()   # Null Object: consistent return type
</code></pre>
<p>To test, try it against a path that doesn't exist:</p>
<pre><code class="language-python">df_missing = load_csv("/tmp/does_not_exist.csv")
print(df_missing.empty)  # True: no crash

# Callers can always do this, instead of an `is None` check:
if df_missing.empty:
    print("No existing data. Will run a full fetch from earliest date.")
</code></pre>
<p><strong>Best practice:</strong> return a <strong>consistent type</strong> from every code path in a function. A function that sometimes returns a <code>DataFrame</code> and sometimes <code>None</code> forces every caller to add a <code>None</code> check before using the result.</p>
<p>Return a <code>DataFrame</code>, empty or not, which keeps things simpler. You never have to ask <em>"did I get a real result, or</em> <code>None</code><em>?"</em> before using it.</p>
<h3 id="heading-incremental-update-logic">Incremental Update Logic</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>This is the "incremental loading" pattern from Part 1 in action. Before you fetch anything, ask: <em>"What's the most recent record I already have, and do I actually need more?"</em></p>
<p><strong>Strategy:</strong> check whether the existing data already covers <em>yesterday</em>.</p>
<ul>
<li><p>Yes: skip the update entirely. Nothing to do, the dataset is current.</p>
</li>
<li><p>No: fetch starting from the day after the last known date.</p>
</li>
</ul>
<pre><code class="language-text">Existing data: Jan. 1 – Jan. 15
Yesterday: Jan. 19

Decision: fetch from Jan. 16 onwards (not from Jan. 1)
</code></pre>
<p>Why <em>yesterday</em> and not <em>today</em>? Today's measurement might not be finalized on the source system yet. Hub'Eau's "elaborated observations" are a processed daily aggregate, so the safest check is against the last <strong>fully completed</strong> day.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>determine_update_range()</code> checks the newest saved date. It tells you either "you're already up to date" or "start downloading from this next date."</p>
<p>Step by step:</p>
<ol>
<li><p><strong>The function starts with existing data</strong></p>
<ul>
<li><code>existing</code> is a pandas <code>DataFrame</code> that already has some rows of data.</li>
</ul>
</li>
<li><p><strong>If there is no data at all</strong></p>
<ul>
<li><p><code>if existing.empty:</code></p>
</li>
<li><p>If the DataFrame has zero rows, it says: <em>"Nothing is saved yet, so fetch everything."</em></p>
</li>
<li><p>It returns:</p>
<ul>
<li><p><code>True</code> = update needed</p>
</li>
<li><p><code>STATION_CONFIG.earliest_date</code> = start from the earliest allowed date</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>Find the date column</strong></p>
<ul>
<li><p>The code checks which column contains dates:</p>
<ul>
<li><p>first tries <code>"date_obs_elab"</code></p>
</li>
<li><p>then <code>"record_date"</code></p>
</li>
</ul>
</li>
<li><p>If neither exists, it raises an error because it doesn’t know which column to use.</p>
</li>
</ul>
</li>
<li><p><strong>Convert the date column into real dates</strong></p>
<ul>
<li><p><code>pd.to_datetime(...)</code> turns the column into date objects pandas can work with.</p>
</li>
<li><p><code>errors="coerce"</code> means bad date values become missing values instead of crashing.</p>
</li>
</ul>
</li>
<li><p><strong>Find the latest date in the data</strong></p>
<ul>
<li><p><code>last_day = s.max().date()</code></p>
</li>
<li><p>This gets the newest date already in the dataset.</p>
</li>
</ul>
</li>
<li><p><strong>Compare it with yesterday</strong></p>
<ul>
<li><p><code>yesterday = date.today() - timedelta(days=1)</code></p>
</li>
<li><p>The function checks whether data already includes yesterday.</p>
</li>
</ul>
</li>
<li><p><strong>If data is already up to date</strong></p>
<ul>
<li><p>If <code>last_day &gt;= yesterday</code>, it returns:</p>
<ul>
<li><p><code>False</code> = no update needed</p>
</li>
<li><p><code>None</code> = no start date needed</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p><strong>If data is behind</strong></p>
<ul>
<li><p>It sets <code>next_day</code> to the day after the last saved date.</p>
</li>
<li><p>Then it returns:</p>
<ul>
<li><p><code>True</code> = update needed</p>
</li>
<li><p>that next day as a string like <code>"2026-07-12"</code></p>
</li>
</ul>
</li>
</ul>
</li>
</ol>
<pre><code class="language-python">def determine_update_range(existing: pd.DataFrame) -&gt; Tuple[bool, Optional[str]]:
    """Determine whether an update is needed and from what date.

    Logic:
    1. Check if existing data covers yesterday's date
    2. If yes → no update needed
    3. If no → start fetching from day after last data

    Returns:
        Tuple[should_update, start_date]
    """
    # Case 1: nothing on disk yet
    if existing.empty:
        print("No existing data found. Will fetch all data from earliest date.")
        return True, STATION_CONFIG.earliest_date

    if "date_obs_elab" in existing.columns:
        record_colname = "date_obs_elab"
    elif "record_date" in existing.columns:
        record_colname = "record_date"
    else:
        raise KeyError("Missing date column: expected 'date_obs_elab' or 'record_date'")

    s = pd.to_datetime(existing[record_colname], errors="coerce")
    last_day = s.max().date()
    yesterday = date.today() - timedelta(days=1)

    # Case 2: already current
    if last_day &gt;= yesterday:
        print("\nDataset already covers yesterday or later. No update needed.")
        return False, None

    # Case 3: fetch the gap
    next_day = (last_day + pd.Timedelta(days=1))
    print(f"\nWill retrieve data starting from: {next_day}")
    return True, next_day.isoformat()
</code></pre>
<p>A few things worth a note here.</p>
<p>First, the function checks for <strong>two possible column names</strong>: <code>date_obs_elab</code>, the raw API name, or the already-renamed English name <code>record_date</code>. It doesn't assume just one. This makes the function work whether you call it on freshly-fetched raw data or an already-processed CSV loaded from disk.</p>
<p><code>errors="coerce"</code> shows up here, and it'll show up again (we'll dig into this in Part 5). Any date pandas can't parse becomes <code>NaT</code> (Not a Time) instead of raising an exception.</p>
<p>The return type is <code>Tuple[bool, Optional[str]]</code>. This <a href="https://www.w3schools.com/python/python_tuples.asp">tuple</a> bundles two related results: <em>should I update, and from when?</em> That beats returning two separate values, or worse, one ambiguous value that means different things depending on context.</p>
<p><strong>Best practice:</strong> use <code>Tuple</code> return types (or, for more fields, a small dataclass or <code>NamedTuple</code>) to bundle related results together. Document clearly what each position means. If you return different <em>types</em> from different code paths without documentation, you'll hit a common source of confusion and bugs: <em>"why is this</em> <code>None</code> <em>sometimes and a string other times?"</em>.</p>
<p>Run a quick check against three scenarios:</p>
<pre><code class="language-python"># Test 1: No existing data
should_update, start_date = determine_update_range(pd.DataFrame())
# → True, "1900-01-01"

# Test 2: Old existing data (covers only Jan 10-15)
# → True, "2026-01-16"  (the day after the last known date)

# Test 3: Recent data that already covers yesterday
# → False, None
</code></pre>
<h3 id="heading-simulate-the-api">Simulate the API</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>In production, fetching means a real HTTP call:</p>
<pre><code class="language-python">requests.get(
    "https://hubeau.eaufrance.fr/api/v2/hydrometrie/obs_elab",
    params={"code_entite": "F700000109", "size": 20000, ...}
)
</code></pre>
<p>Real API calls bring real challenges. Rather than fight those on your first read-through, this tutorial first builds and tests everything against a <strong>mock</strong> generator. It returns data shaped exactly like the real API. Only once the logic works does it swap in the real endpoint.</p>
<p>This technique is useful well beyond this project. Build and test your transform logic against fixtures or mocks first. That way, you're not into "is my parsing wrong?" and "is the network flaky right now?" at the same time.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>generate_mock_api_data()</code> generates fake sample data for a station, one record per day, starting from <code>start_date</code>.</p>
<p>How it works:</p>
<ul>
<li><p>It converts <code>start_date</code> into a real date.</p>
</li>
<li><p>It loops for <code>num_days</code>.</p>
</li>
<li><p>For each day, it creates a mock observation dictionary.</p>
</li>
<li><p>It adds a small random variation to the water level so the data looks realistic.</p>
</li>
<li><p>It randomly picks status, quality, and method labels.</p>
</li>
<li><p>It returns a list of these dictionaries.</p>
</li>
</ul>
<pre><code class="language-python">def generate_mock_api_data(station_code: str, start_date: str, num_days: int = 10) -&gt; List[Dict]:
    """Generate realistic mock API data for demonstration.

    Simulates what HubEau API would return: list of observation dicts.
    """
    start = pd.to_datetime(start_date).date()
    records = []

    validation_statuses = ["Donnée validée", "Donnée brute", "Donnée pré-validée"]
    qualities = ["Bonne", "Non qualifiée", "Douteuse"]
    methods = ["Mesurée", "Calculée", "Expertisée"]

    for i in range(num_days):
        obs_date = start + timedelta(days=i)
        base_level = 5500 + int(station_code[-2:])  # Varies by station
        noise = random.randint(-200, 200)
        water_level = base_level + noise

        record = {
            "code_site": "mock_" + station_code[1:],
            "code_station": "mock_" + station_code,
            "date_obs_elab": obs_date.isoformat(),
            "resultat_obs_elab": water_level,
            "date_prod": (obs_date + timedelta(days=1)).isoformat(),
            "code_statut": "1",
            "libelle_statut": random.choice(validation_statuses),
            "code_methode": "1",
            "libelle_methode": random.choice(methods),
            "code_qualification": "1",
            "libelle_qualification": random.choice(qualities),
            "longitude": 2.3522 + random.uniform(-0.01, 0.01),
            "latitude": 48.8566 + random.uniform(-0.01, 0.01),
            "grandeur_hydro_elab": "mock_HIXnJ",
        }
        records.append(record)

    return records
</code></pre>
<h3 id="heading-fetch-data-for-real">Fetch Data for Real</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>Two functions handle extraction. They're deliberately split according to the <a href="https://en.wikipedia.org/wiki/Single-responsibility_principle"><strong>Single Responsibility Principle</strong></a> (SRP): each function should have one reason to change.</p>
<pre><code class="language-text">fetch_all_data() (orchestrator)
    ├── fetch_single_station_data(station_1) ← handles all complexity
    ├── fetch_single_station_data(station_2) ← handles all complexity
    └── fetch_single_station_data(station_n) ← handles all complexity
</code></pre>
<ul>
<li><p><code>fetch_single_station_data()</code> owns <em>all</em> the messy per-station complexity: pagination, cursor advancement, stop conditions, and network error handling.</p>
</li>
<li><p><code>fetch_all_data()</code> owns none of that. It just loops over stations and delegates.</p>
</li>
</ul>
<p>This split has two payoffs. First, you can debug or swap out the pagination strategy for one station without touching the orchestration code at all. Second, if you ever want to parallelize fetching (with <code>concurrent.futures</code> or <code>asyncio</code>, for example), the orchestrator is the <em>only</em> place you'd need to touch.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>fetch_single_station_data()</code> fetches station data either from mock test data or from the real API, page by page, until it has everything it needs.</p>
<ul>
<li><p><strong>If</strong> <code>use_mock=True</code>, it uses <strong>fake data</strong> instead of calling the real API.</p>
<ol>
<li><p>It calls <code>generate_mock_api_data()</code></p>
</li>
<li><p>Turns the result into a DataFrame</p>
</li>
<li><p>Converts the date column into real pandas dates</p>
</li>
<li><p>Returns that DataFrame</p>
</li>
</ol>
</li>
<li><p><strong>If</strong> <code>use_mock=False</code>, it does the <strong>real API request</strong>:</p>
<ol>
<li><p>Creates a reusable HTTP session</p>
</li>
<li><p>Starts from <code>start_date</code></p>
</li>
<li><p>Repeatedly asks the API for a page of data</p>
</li>
<li><p>Stops when:</p>
<ul>
<li><p>the API returns no data</p>
</li>
<li><p>the latest date reaches yesterday</p>
</li>
<li><p>the page is smaller than expected</p>
</li>
<li><p>a network error happens</p>
</li>
</ul>
</li>
<li><p>Combines all pages into one DataFrame</p>
</li>
<li><p>Returns an empty DataFrame if nothing was fetched</p>
</li>
</ol>
</li>
</ul>
<pre><code class="language-python">def fetch_single_station_data(station_code: str, start_date: str, use_mock: bool = True) -&gt; pd.DataFrame:
    """Fetch all hydrometric data for a single station from mock API (default) or from the real endpoint.

    In production (cursor based pagination strategy):
    - Fetches max_per_page records per request
    - Continues until no new data or yesterday's date reached
    - Stop when: no data returned | last date &gt;= yesterday | page was not full
    - Handles network errors gracefully
    """
    if use_mock:
        data = generate_mock_api_data(station_code, start_date, num_days=7)
        page_df = pd.DataFrame(data)
        page_df["date_obs_elab"] = pd.to_datetime(
            page_df["date_obs_elab"], errors="coerce").dt.normalize()
        return page_df

    # Real implementation
    else:
        session = requests.Session()  # Reuse TCP connection across pages
        frames = []
        cursor = start_date

        while True:
            params = {
                "code_entite": station_code,
                "grandeur_hydro_elab": API_CONFIG.metric,
                "date_debut_obs_elab": cursor,
                "size": API_CONFIG.max_per_page,
            }

            try:
                response = session.get(
                    API_CONFIG.base_url,
                    params=params,
                    timeout=API_CONFIG.timeout_seconds  # Best practice, always set
                )
                response.raise_for_status()
            except requests.RequestException as e:  # Don't let one station kill the whole pipeline
                print(f"Error fetching data for station {station_code}: {e}")
                break

            data = response.json().get("data", [])
            if not data:  # Empty response: we've exhausted this station
                break

            page_df = pd.DataFrame(data)
            page_df["date_obs_elab"] = pd.to_datetime(
                page_df["date_obs_elab"], errors="coerce").dt.normalize()
            frames.append(page_df)

            last_page_date = page_df["date_obs_elab"].max()
            yesterday = date.today() - timedelta(days=1)

            # Prevent infinite loops
            if pd.isna(last_page_date) or last_page_date.date() &gt;= yesterday:
                break

            cursor = (last_page_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d")

            if len(data) &lt; API_CONFIG.max_per_page:
                break

    if frames:
        return pd.concat(frames, ignore_index=True)
    return pd.DataFrame()
</code></pre>
<p><strong>How does the pagination loop actually work?</strong></p>
<p>Let's walk through it step by step. This is the densest bit of logic in the whole notebook.</p>
<ol>
<li><p>Send a request with <code>date_debut_obs_elab=cursor</code>: "give me records from this date on."</p>
</li>
<li><p>If the request fails outright (<code>requests.RequestException</code>), log it and <code>break</code>. <code>break</code> stops the loop right away and moves on. One station's network hiccup shouldn't kill the pipeline for every other station.</p>
</li>
<li><p>If the response has no data at all, you've caught up: <code>break</code>.</p>
</li>
<li><p>Otherwise, note the <em>latest</em> date seen on this page (<code>last_page_date</code>).</p>
</li>
<li><p>If that latest date is already <code>&gt;= yesterday</code>, you've caught up: <code>break</code>.</p>
</li>
<li><p>Otherwise, advance the cursor to <code>last_page_date + 1 day</code> and loop again for the next page.</p>
</li>
<li><p>As a safety net: if the page returned <em>fewer</em> records than <code>max_per_page</code>, that also means you've reached the end. The API wouldn't return a partial page unless it ran out of data, so: <code>break</code>.</p>
</li>
</ol>
<p>That last check (step 7) is a classic <strong>pagination termination heuristic</strong>. You don't always need a <code>next_page</code> token from the API. If a full page is <code>size=20000</code> and you get back only <code>4213</code> records, there's nothing left to fetch.</p>
<p><strong>Three specific, deliberate choices, called out:</strong></p>
<pre><code class="language-python">session = requests.Session()
</code></pre>
<p>A <a href="https://requests.readthedocs.io/en/latest/user/advanced/#session-objects"><code>Session</code></a> object reuses the underlying TCP connection across multiple requests to the same host. That avoids a fresh TCP/TLS handshake on every single page request. In short, it's faster for you and more polite to Hub'Eau's servers.</p>
<p><strong>Best practice:</strong> any time you call <code>requests.get()</code> more than once against the same host in a loop, reach for a <code>Session</code>.</p>
<pre><code class="language-python">except requests.RequestException as e:
</code></pre>
<p><code>RequestException</code> is the base class for <a href="https://requests.readthedocs.io/en/latest/api/#requests.RequestException">every exception</a> <code>requests</code> can raise: timeouts, connection errors, HTTP errors from <code>raise_for_status()</code> and more. Catching the base class here means <em>any</em> network hiccup gets handled the same forgiving way: log it, stop fetching this station, move on.</p>
<pre><code class="language-python">timeout=API_CONFIG.timeout_seconds
</code></pre>
<p>By default, <code>requests</code> calls <strong>never time out</strong>. Without an explicit timeout, a hung server can freeze your entire pipeline indefinitely. The <a href="https://requests.readthedocs.io/en/latest/user/advanced/#timeouts">requests advanced usage docs</a> states that requests to external servers should have a timeout attached.</p>
<p><strong>Best practice:</strong> wrap every external I/O call in <code>try/except</code>. Always fail gracefully. Log the error and let the pipeline recover or move on. This avoids one flaky request taking down an unattended weekly job.</p>
<p>Now the orchestrator, <code>fetch_all_data()</code>, is deliberately much simpler:</p>
<ol>
<li><p>Loop through all station codes.</p>
<ul>
<li><p>Fetch each station's data.</p>
</li>
<li><p>Keep the non-empty results.</p>
</li>
</ul>
</li>
<li><p>Combine them into one big DataFrame.</p>
</li>
</ol>
<pre><code class="language-python">def fetch_all_data(start_date: str, use_mock: bool = True) -&gt; pd.DataFrame:
    """Orchestrator: Fetch data for all configured stations."""
    frames = []

    for station_code in STATION_CONFIG.station_codes:
        print(f"Fetching data for station {station_code}...")
        df_station = fetch_single_station_data(station_code, start_date, use_mock=use_mock)

        if not df_station.empty:
            print(f"  Got {len(df_station)} records")
            frames.append(df_station)
        else:
            print(f"  (no data)")

    if frames:
        return pd.concat(frames, ignore_index=True)
    return pd.DataFrame()
</code></pre>
<p>That's it. A loop and a <code>pd.concat</code>. All the hard-won complexity lives one layer down, exactly where SRP says it should.</p>
<h2 id="heading-part-5-the-transform-step">Part 5: The Transform Step</h2>
<p>This is where raw, freshly-fetched data becomes something you can publish. Here's the design philosophy for this whole section: compose many small, <strong>pure functions</strong>. Each one takes a DataFrame in and returns a <em>new</em> DataFrame out without side effects. Avoid the one giant do-everything function trap.</p>
<pre><code class="language-text">    (EXTRACT)
    Raw API Data
    ↓
    (TRANSFORM)
1. Type parsing (datetime, numeric)
2. Column renaming (French → English)
3. Categorical mapping (validation status, quality)
4. Derived columns computation (flood alert flags)
5. Column reordering (logical grouping)
6. Sorting &amp; index reset
    ↓
    (LOAD)
    Publication-ready dataset
</code></pre>
<p>Why split this into six tiny steps instead of one big function? Each piece is testable and replaceable on its own. When something breaks at 3am on a scheduled run, every function is debuggable in isolation. You can pinpoint exactly which stage produced bad output. No need to pick apart one 200-line function.</p>
<h3 id="heading-type-parsing-and-graceful-coercion">Type Parsing and Graceful Coercion</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>Data from a CSV or a JSON API starts out, by default, as strings. Pandas needs real types to sort dates chronologically, do date arithmetic (<code>last_date + timedelta(days=1)</code>), or compare numeric values (<code>water_level &gt; 6000</code>). Type mismatches are a common error that break batch pipelines. One malformed row, like <code>"N/A"</code>, a truncated date, misaligned values, or stray characters and a strict parser throws an exception that kills the whole run.</p>
<p><strong>Best practice:</strong> both conversion functions below use <code>errors="coerce"</code>, so values pandas can't parse become <code>NaT</code> (Not a Time) or <code>NaN</code> (Not a Number) instead of raising an error. This is called <a href="https://stackoverflow.com/questions/36394814/what-is-the-significance-of-coerce-in-python-pandas">graceful coercion</a>.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>convert_to_date()</code> and <code>convert_to_numeric()</code> are small helper functions that make sure certain columns have the right type.</p>
<ul>
<li><p>Both start with <code>df.copy()</code> so they don’t change the original DataFrame.</p>
</li>
<li><p><code>errors="coerce"</code> means bad values become missing values instead of causing a crash.</p>
</li>
</ul>
<p>For <code>convert_to_date()</code>:</p>
<ol>
<li><p><code>df.copy()</code> makes a separate copy, so the original table stays unchanged</p>
</li>
<li><p>The loop goes through each column name in <code>columns</code></p>
<ul>
<li><p><code>if col in df.columns</code> checks that the column actually exists before trying to convert it</p>
</li>
<li><p><code>pd.to_datetime(...)</code> turns text like <code>"2026-07-12"</code> into real pandas date/time values</p>
</li>
<li><p><code>errors="coerce"</code> means invalid values become <code>NaT</code> (missing date) instead of raising an error</p>
</li>
<li><p><code>.dt.normalize()</code> removes the time part and keeps only the date at midnight</p>
</li>
</ul>
</li>
</ol>
<p>For <code>convert_to_numeric()</code>:</p>
<ol>
<li><p><code>df.copy()</code> is used here as well.</p>
</li>
<li><p>The loop goes through each column name in <code>columns</code></p>
<ul>
<li><p><code>if col in df.columns</code> checks that the column actually exists before trying to convert it</p>
</li>
<li><p>The loop calls <code>pd.to_numeric()</code> turns text like <code>"12.5"</code> into numbers</p>
</li>
<li><p><code>errors="coerce"</code> turns bad values into <code>NaN</code> instead of crashing</p>
</li>
</ul>
</li>
</ol>
<pre><code class="language-python">def convert_to_date(df: pd.DataFrame, columns: List[str]) -&gt; pd.DataFrame:
    """Convert specified columns to pandas datetime type."""
    df = df.copy()  # Never modify the original!
    for col in columns:
        if col in df.columns:
            df[col] = pd.to_datetime(df[col], errors="coerce").dt.normalize()
    return df


def convert_to_numeric(df: pd.DataFrame, columns: List[str]) -&gt; pd.DataFrame:
    """Convert specified columns to numeric (float) type."""
    df = df.copy()
    for col in columns:
        if col in df.columns:
            df[col] = pd.to_numeric(df[col], errors="coerce")
    return df
</code></pre>
<p>Why is this useful?</p>
<ul>
<li><p>You can aggregate the data: group it, summarize it and so on...</p>
</li>
<li><p>Dates become sortable and filterable as real dates.</p>
</li>
<li><p>Numbers work correctly in calculations like averages, sums, or comparisons.</p>
</li>
<li><p>It prevents bugs caused by mixed types, like <code>"12"</code> and <code>12</code>.</p>
</li>
</ul>
<p>Both <code>pd.to_datetime</code> and <code>pd.to_numeric</code> are official pandas functions with an <code>errors</code> parameter. By default, that parameter is set to <code>"raise"</code>, which throws on bad input. The other options are <code>"coerce"</code> (replace with null) or <code>"ignore"</code> (leave untouched). See the <a href="https://pandas.pydata.org/docs/reference/api/pandas.to_datetime.html">pandas <code>to_datetime</code> docs</a> and <a href="https://pandas.pydata.org/docs/reference/api/pandas.to_numeric.html"><code>to_numeric</code> docs</a> for the full parameter list.</p>
<p>Test it against an example of messy input:</p>
<pre><code class="language-python">messy_df = pd.DataFrame({
    "date_obs_elab": ["2026-01-15", "2026-01-16", "not a date", None],
    "resultat_obs_elab": [5800.0, "5900", "N/A", None],
})

type_safe_df = convert_to_date(messy_df, ["date_obs_elab"])
type_safe_df = convert_to_numeric(type_safe_df, ["resultat_obs_elab"])

# "not a date"  → NaT
# "N/A"         → NaN
# Pipeline continues safely — nothing crashed.
</code></pre>
<p><strong>Best practice:</strong> don't let one bad row kill an entire run. Coerce bad data to nulls rather than raising exceptions. Flag or log nulls separately if you need to investigate data quality later.</p>
<p>This is a deliberate trade-off. A choice between <em>availability</em> (the pipeline keeps running) over <em>strictness</em> (catching every bad row immediately). That's usually the right call for a scheduled, unattended job.</p>
<h4 id="heading-dfcopy-immutability-by-convention"><code>df.copy()</code>: immutability by convention</h4>
<p>Look again at the top of both functions above: <code>df = df.copy()</code>. This single line appears at the start of <strong>every transform function</strong> in the pipeline, and that's not an accident.</p>
<p>Python DataFrames are mutable objects, passed by reference. If a function modifies <code>df</code> in place without copying first, the caller's original DataFrame changes too. That's a classic <a href="https://en.wikipedia.org/wiki/Side_effect_(computer_science)">side effect</a> and it can produce genuinely confusing bugs. Call <code>.copy()</code> first means each function's output is a brand-new object. The input the caller passed in stays <em>guaranteed untouched</em>.</p>
<p><strong>Best practice:</strong> treat DataFrames as <strong>immutable inputs</strong>. Return a new DataFrame rather than modify it one in place. Even if it costs a small amount of memory or CPU, the debugging win is almost always worth it for a pipeline that isn't operating at extreme scale.</p>
<p>Finally, one auto-detecting convenience function wraps these two low-level functions. It scans text columns, guesses whether they contain dates or numbers, and calls the matching parsing function above.</p>
<p><code>auto_convert_columns()</code> tries to <strong>guess which columns are dates or numbers</strong> and then fixes these types automatically.</p>
<p>How it works:</p>
<p>It starts with two empty lists:</p>
<ul>
<li><p><code>datetime_cols</code> for date columns</p>
</li>
<li><p><code>numeric_cols</code> for number columns</p>
</li>
</ul>
<p>It goes through each column in the DataFrame. If a column is already a real datetime or numeric type, it skips it. If the column is text-like (<code>object</code> or <code>string</code>), it looks at up to 10 non-empty sample values.</p>
<p>It first tries to read those values as dates:</p>
<ul>
<li>if that works, the column is added to <code>datetime_cols</code></li>
</ul>
<p>If not, it tries to read them as numbers:</p>
<ul>
<li>if that works, the column is added to <code>numeric_cols</code></li>
</ul>
<p>At the end, it converts all date columns with <code>convert_to_date()</code>. Then it converts all numeric columns with <code>convert_to_numeric()</code></p>
<pre><code class="language-python">def auto_convert_columns(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Auto-detect and convert datetime and numeric columns to the correct type."""
    datetime_cols = []
    numeric_cols = []

    for col in df.columns:
        if pd.api.types.is_datetime64_any_dtype(df[col]):
            continue
        if pd.api.types.is_numeric_dtype(df[col]):
            continue

        if pd.api.types.is_object_dtype(df[col]) or pd.api.types.is_string_dtype(df[col]):
            sample = df[col].dropna().head(10)
            if len(sample) == 0:
                continue

            try:
                pd.to_datetime(sample, errors='raise', format='mixed')
                datetime_cols.append(col)
                continue
            except (ValueError, TypeError):
                pass

            try:
                pd.to_numeric(sample, errors='raise')
                numeric_cols.append(col)
                continue
            except (ValueError, TypeError):
                pass

    df = convert_to_date(df, datetime_cols)
    df = convert_to_numeric(df, numeric_cols)
    return df
</code></pre>
<p>Notice the inner <code>try/except</code> blocks here use <code>errors='raise'</code>. That's the <em>opposite</em> of the coercion strategy above, but it only runs against a small <code>.head(10)</code> <strong>sample</strong> of each column.</p>
<p>This is a type-<em>sniffing</em> step, not the final conversion. It tests <em>"does this column look like dates, or numbers, or neither?"</em> on a cheap sample. Then, it hands off the actual, forgiving conversion of the <em>whole</em> column to <code>convert_to_date()</code> or <code>convert_to_numeric()</code>. Two different <code>errors</code> strategies, two different jobs.</p>
<h3 id="heading-schema-translation-with-bidirectional-mappings">Schema Translation with Bidirectional mappings</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>The Hub'Eau API returns French column names and French categorical values, like <code>code_station</code> or <code>"Donnée validée"</code>. A dataset meant for an international audience should ship in English. If you rename columns inline, wherever it's convenient, the mapping between French and English ends up scattered across the codebase. Then you'd have no way to reverse it if you ever needed to.</p>
<p><strong>The fix:</strong> define <strong>one authoritative mapping</strong> at the top of the module, and derive everything else from it.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>API_TO_EN</code> maps API field names to English translations.</p>
<pre><code class="language-python"># Primary mapping: French API columns to English column names
API_TO_EN = {
    "code_site": "location_code",
    "code_station": "station_code",
    "date_obs_elab": "record_date",
    "resultat_obs_elab": "water_level_mm",
    "date_prod": "data_production_date",
    "code_statut": "validation_status_code",
    "libelle_statut": "validation_status",
    "code_methode": "production_method_code",
    "libelle_methode": "production_method",
    "code_qualification": "quality_code",
    "libelle_qualification": "quality_assessment",
    "longitude": "longitude",
    "latitude": "latitude",
    "grandeur_hydro_elab": "hubeau_elab_code",
}

# Reverse mapping: English to French (computed automatically)
EN_TO_API = {v: k for k, v in API_TO_EN.items()}
</code></pre>
<p>The second line (<code>EN_TO_API</code>) is just a shortcut: it swaps each key and value from <code>API_TO_EN</code>. A <a href="https://docs.python.org/3/tutorial/datastructures.html#dictionaries">dict comprehension</a> builds it. <strong>Here's the important design point:</strong> <code>EN_TO_API</code> isn't hand-maintained, it's <em>derived</em>. If you add, remove, or rename an entry in <code>API_TO_EN</code>, <code>EN_TO_API</code> updates automatically the next time the module runs.</p>
<p>There's <em>exactly one place</em> in the entire codebase where a schema change needs to happen.</p>
<p>Categorical <em>values</em> (not just column names) get the same treatment:</p>
<pre><code class="language-python">CATEGORICAL_MAPPINGS = {
    "validation_status": {
        "Donnée validée": "validated",
        "Donnée brute": "raw",
        "Donnée pré-validée": "pre-validated",
    },
    "quality_assessment": {
        "Bonne": "good",
        "Non qualifiée": "unqualified",
        "Douteuse": "dubious",
    },
    "production_method": {
        "Calculée": "calculated",
        "Mesurée": "measured",
        "Expertisée": "expert-reviewed",
    },
}
</code></pre>
<p>The functions below apply these mappings, in order, to standardize column names and category values.</p>
<p><code>rename_to_english()</code>:</p>
<ol>
<li><p>If the DataFrame is empty, it returns a copy right away.</p>
</li>
<li><p>It builds a list of columns that are renamed from API names to English names.</p>
<ul>
<li><p>It only renames a column if:</p>
<ul>
<li><p>the old name exists, and</p>
</li>
<li><p>the new name does not already exist</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Then it returns the renamed DataFrame.</p>
</li>
</ol>
<p><code>rename_to_api_schema()</code>:</p>
<ol>
<li><p>Also returns a copy for empty data.</p>
</li>
<li><p>Does the reverse: English names back to API names.</p>
</li>
<li><p>Returns the DataFrame.</p>
</li>
</ol>
<p>(Useful if you need to send data back in the API’s original format).</p>
<p><code>apply_categorical_mappings()</code>:</p>
<ol>
<li><p>Makes a copy so the original DataFrame is not changed.</p>
</li>
<li><p>For each column in <code>CATEGORICAL_MAPPINGS</code>, it replaces values using the mapping.</p>
<ul>
<li><p>Example: <code>"Donnée validée"</code> becomes <code>"validated"</code>.</p>
</li>
<li><p><code>.fillna(df[col_name])</code> keeps the original value if a value isn’t found in the mapping.</p>
</li>
</ul>
</li>
<li><p>Returns the DataFrame.</p>
</li>
</ol>
<pre><code class="language-python">def rename_to_english(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Rename API column names to English schema names."""
    if df.empty:
        return df.copy()

    columns_to_rename = {}
    for src, dst in API_TO_EN.items():
        if src in df.columns and dst not in df.columns:
            columns_to_rename[src] = dst

    return df.rename(columns=columns_to_rename)

def rename_to_api_schema(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Rename English column names back to API schema names (defensive/reverse operation)."""
    if df.empty:
        return df.copy()

    columns_to_rename = {k: v for k, v in EN_TO_API.items() if k in df.columns}
    return df.rename(columns=columns_to_rename)
</code></pre>
<pre><code class="language-python">def apply_categorical_mappings(df: pd.DataFrame) -&gt; pd.DataFrame:
    df = df.copy()
    for col_name, mapping in CATEGORICAL_MAPPINGS.items():
        if col_name in df.columns:
            df[col_name] = df[col_name].map(mapping).fillna(df[col_name])
    return df
</code></pre>
<p>Two defensive habits are worth a note here:</p>
<p><strong>Robustness to partial inputs:</strong> Both rename functions only rename columns that actually exist in the input (<code>if src in df.columns</code>).</p>
<p>A rename function that assumes every mapped column is always present will crash the moment it's called on a partial or differently-shaped DataFrame.</p>
<p><code>.map(mapping).fillna(df[col_name])</code>. <a href="https://pandas.pydata.org/docs/reference/api/pandas.Series.map.html"><code>Series.map</code></a> replaces every value it finds in the mapping dict and turns any value <em>not</em> found in the dict into <code>NaN</code>. Chain <code>.fillna(df[col_name])</code> right after restores the <em>original</em> value wherever the mapping didn't apply. That way, unexpected categorical values pass through unchanged instead of silently becoming null. It's a subtle but important robustness choice.</p>
<p>See this exact <code>.map()</code>-then-<code>.fillna()</code> idiom discussed on <a href="https://stackoverflow.com/questions/19798153/difference-between-map-applymap-and-apply-methods-in-pandas">Stack Overflow: pandas map vs apply performance</a>.</p>
<p>A quick round-trip test proves the bidirectional mapping actually works:</p>
<pre><code class="language-python">sample_api_df = pd.DataFrame({"code_station": ["F700000109"], ...})
renamed_df = rename_to_english(sample_api_df)          # French → English
reversed_df = rename_to_api_schema(renamed_df)          # English → French
# reversed_df.columns.tolist() == sample_api_df.columns.tolist()  → True
</code></pre>
<h3 id="heading-compute-flood-alerts">Compute Flood Alerts</h3>
<p><code>add_derived_columns()</code> is a one-liner. It just computes <code>flood_alert</code>. If the water level is greater than the flood threshold set in the <code>Config</code>, the result is <code>True</code>. Otherwise, the result is <code>False</code>.</p>
<pre><code class="language-python">def add_derived_columns(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Add a computed columns based on raw data.
    """
    df = df.copy()

    if "water_level_mm" in df.columns:
        df["flood_alert"] = df["water_level_mm"] &gt; STATION_CONFIG.flood_threshold_mm

    return df
</code></pre>
<h3 id="heading-column-ordering">Column Ordering</h3>
<p>Here's a small but user-facing detail: define a preferred column order once, as data, and reuse it everywhere. <code>COLUMN_ORDER</code> holds that preferred column sequence.</p>
<p><code>order_columns()</code> rearranges a DataFrame so those columns come first, while any extra columns stay at the end.</p>
<pre><code class="language-python">COLUMN_ORDER = [
    # Primary identifiers &amp; measurements
    "station_code", "record_date", "water_level_mm", "flood_alert",
    # Metadata about the observation
    "hubeau_elab_code", "data_production_date",
    "validation_status_code", "validation_status",
    "production_method_code", "production_method",
    "quality_code", "quality_assessment",
    # Geographic info (less important)
    "location_code", "longitude", "latitude",
]

def order_columns(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Reorder columns to preferred order."""
    present_cols = [c for c in COLUMN_ORDER if c in df.columns]
    other_cols = [c for c in df.columns if c not in present_cols]
    return df[present_cols + other_cols]
</code></pre>
<p><code>other_cols</code> acts as a safety net: any column not explicitly listed in <code>COLUMN_ORDER</code> still gets included at the end. They're not silently dropped.</p>
<p>Rules, in order of priority:</p>
<ul>
<li><p>Keep identifiers and key fields up front.</p>
</li>
<li><p>Put the most important, most frequently used, and most stable columns first.</p>
</li>
<li><p>Group related fields together, so the table reads naturally.</p>
</li>
<li><p>Push optional or rarely-used fields to the end.</p>
</li>
</ul>
<h3 id="heading-deduplication">Deduplication</h3>
<h4 id="heading-design-logic">Design logic</h4>
<p>Remove duplicates as you go. In an incremental pipeline, date ranges and records can overlap:</p>
<ul>
<li><p>The same day might get re-fetched, because API data arrives late or gets finalized later.</p>
</li>
<li><p>The same observation might appear on two different pages of a paginated response.</p>
</li>
</ul>
<p>Without deduplication, these scenarios let duplicate rows pile up in the dataset over time. This is also, recall from Part 1, exactly what makes the pipeline <strong>idempotent</strong>: run it once or run it five times and the resulting dataset stays identical.</p>
<p>The fix requires defining what makes a record <strong>unique</strong>. Define a composite key:</p>
<pre><code class="language-text">key = (station_code, observation_date, water_level_value)
</code></pre>
<p>Why this specific combination? Physically, one sensor (<code>station_code</code>) reports one day's (<code>observation_date</code>) daily-maximum reading (<code>water_level_mm</code>), and that reading should be unique.</p>
<ul>
<li><p>Records from different stations obviously aren't duplicates of each other.</p>
</li>
<li><p>Two readings on different days aren't duplicates.</p>
</li>
<li><p>A subtler point: if the <em>same</em> station reports the <em>same</em> day but with a <em>different</em> value, that counts as a distinct observation, for example a corrected or revised measurement, not a duplicate to silently discard.</p>
</li>
</ul>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>create_dedup_key()</code> builds a unique text ID for each row by joining three pieces together with an underscore:</p>
<ul>
<li><p>station code</p>
</li>
<li><p>date, as YYYY-MM-DD</p>
</li>
<li><p>water level value</p>
</li>
</ul>
<p>Example key:</p>
<pre><code class="language-text">"F700000109_2024-01-15_5800.0"
</code></pre>
<pre><code class="language-python">def create_dedup_key(df: pd.DataFrame) -&gt; pd.Series:
    """Create unique deduplication key from station, day, and water level value.

    Key format: "station_code_YYYY-MM-DD_value"
    Example: "F700000109_2024-01-15_5800.0"
    """
    parts = []

    if "code_station" in df.columns:
        parts.append(df["code_station"].astype(str))

    if "date_obs_elab" in df.columns:
        parts.append(df["date_obs_elab"].dt.strftime("%Y-%m-%d"))

    if "resultat_obs_elab" in df.columns:
        parts.append(df["resultat_obs_elab"].astype(str))

    if not parts:
        return pd.Series(index=df.index, dtype="object")

    return pd.Series(
        ["_".join(row) for row in zip(*parts)],
        index=df.index
    )
</code></pre>
<p><strong>How the key-building works:</strong> <code>parts</code> ends up as a list of <a href="https://www.geeksforgeeks.org/pandas/python-pandas-series/">Series</a>, one per key component (station, date, value), each the same length as the DataFrame.</p>
<p>Read the last line from the inside out:</p>
<pre><code class="language-python">    return pd.Series(["_".join(row) for row in zip(*parts)], index=df.index)
</code></pre>
<p><code>zip(*parts)</code> transposes that list of columns into row-wise tuples. It yields <code>(station_1, date_1, value_1)</code>, then <code>(station_2, date_2, value_2)</code>, and so on. The <a href="https://www.geeksforgeeks.org/python/python-list-comprehension/">list comprehension</a> then joins each row-tuple with underscores into one string key per row.</p>
<p>This <em>"list of columns → zip → row tuples"</em> idiom is a common and efficient way to combine several Series into one derived Series, without writing <code>.apply(lambda row: ..., axis=1)</code>. Row-wise <code>.apply</code> is notoriously <a href="https://stackoverflow.com/questions/54432583/when-should-i-not-want-to-use-pandas-apply-in-my-code">slow in pandas</a> compared to <a href="https://www.datacamp.com/es/tutorial/pandas-iterate-over-rows">vectorized</a> string operations.</p>
<p>The actual deduplication happens in <code>remove_duplicates()</code>. It removes rows from <em>"new"</em> (freshly fetched data) that already exist in <em>"existing"</em> (historic data).</p>
<p>Step by step:</p>
<ol>
<li><p>If one table is empty, it just returns <code>new</code>.</p>
</li>
<li><p>It enforces types in both DataFrames with <code>auto_convert_columns()</code> first, so dates and numbers compare correctly.</p>
</li>
<li><p>It creates a deduplication key for each row in both tables with <code>create_dedup_key()</code>.</p>
</li>
<li><p>It checks which keys from the fetched data don't appear in the existing historic dataset, and keeps only truly new rows.</p>
</li>
<li><p>It returns the filtered result, keeping the original columns from <code>new</code>.</p>
</li>
</ol>
<pre><code class="language-python">def remove_duplicates(existing: pd.DataFrame, new: pd.DataFrame) -&gt; pd.DataFrame:
    """Remove rows from 'new' that already exist in 'existing'."""
    # Short-circuit: if either is empty, no work to do
    if existing.empty or new.empty:
        return new.copy()

    # Parse types on both sides for a fair comparison
    existing_std = auto_convert_columns(existing)
    new_std = auto_convert_columns(new)

    # Build the keys
    existing_keys = set(create_dedup_key(existing_std).dropna())
    new_keys = create_dedup_key(new_std)

    # Boolean mask: True where the new row is genuinely new
    mask = ~new_keys.isin(existing_keys)

    # Index back into the ORIGINAL (non-standardized) 'new' to preserve all columns
    result = new.iloc[new_keys[mask].index].copy()
    return result
</code></pre>
<p>Two performance and robustness details are worth to note:</p>
<p>First, <code>existing_keys</code> <strong>is a</strong> <code>set</code><strong>, not a</strong> <code>list</code><strong>.</strong> Testing membership (<code>in</code> / <code>.isin()</code>) against a Python <code>set</code> is <a href="https://robbell.io/2009/06/a-beginners-guide-to-big-o-notation"><strong>O(1)</strong></a> on average, because it uses a fast hash lookup to answer <em>is this item already here?</em> directly. Testing against a <code>list</code> is <strong>O(n)</strong>: it has to scan item by item and it gets slower as the existing dataset grows.</p>
<p>For a dataset with tens of thousands of rows, checked on every single pipeline run, that difference matters and that's a textbook example of choosing the right data structure for the job. See the general discussion of <a href="https://stackoverflow.com/questions/513882/python-list-vs-dict-for-look-up-table">list vs. set lookup performance in Python</a> and this <a href="https://thelinuxcode.com/pandas-value-list/">guidance</a> on <a href="https://stackoverflow.com/questions/61515457/fastest-way-to-filter-a-pandas-dataframe-using-a-list"><code>.isin()</code> performance for filtering</a>.</p>
<p><strong>Simple rule:</strong> use a <code>set</code> when you care about fast membership checks. Use a <code>list</code> when you care about order or duplicates.</p>
<p>Second, <code>new.iloc[new_keys[mask].index]</code> <strong>indexes back into the <em>original</em>, non-validated</strong> <code>new</code> <strong>DataFrame</strong>, not <code>new_std</code>.</p>
<p>Why? <code>auto_convert_columns()</code> only ran to get <em>consistent types for comparison</em>. The caller still wants the <em>original</em> raw values and schema back for everything that survives deduplication.</p>
<p>Don't let a side-computation <em>accidentally</em> become your source of truth. Always modify a copy only to decide what to keep or remove. Once you've made that decision, apply it to the original data. That way you preserve the real source values for later steps.</p>
<p>In short:</p>
<ol>
<li><p>First modifications are only for comparison.</p>
</li>
<li><p>Use that comparison to filter the original data.</p>
</li>
<li><p>Return the original rows unchanged, so later stages can process them.</p>
</li>
</ol>
<p><strong>Best practice:</strong> keep your deduplication key simple and stable (immutable) and always short-circuit (with <code>if existing.empty or new.empty: return new.copy()</code>) before doing any heavier work. This cheap guard clause skips the expensive deduplication logic when there's no data to process.</p>
<h3 id="heading-put-it-all-together-in-postprocess">Put it All Together in <code>postprocess()</code></h3>
<p>All the pieces above are small and testable on their own. The last step of <strong>Transform</strong> glues them together, <em>in order</em>, into the single pipeline function below:</p>
<pre><code class="language-python">def postprocess(df: pd.DataFrame) -&gt; pd.DataFrame:
    """Apply all post-processing transformations."""
    if df.empty:
        return df

    df = df.copy()

    print("  1. Converting types...")
    df = auto_convert_columns(df)

    print("  2. Renaming columns (French → English)...")
    df = rename_to_english(df)

    print("  3. Mapping categorical values...")
    df = apply_categorical_mappings(df)

    print("  4. Adding derived columns...")
    df = add_derived_columns(df)

    print("  5. Reordering columns...")
    df = order_columns(df)

    print("  6. Sorting and resetting index...")
    df = df.sort_values(["record_date", "station_code"]).reset_index(drop=True)

    return df
</code></pre>
<p>Notice the shape of this function: it's basically a linear script with six numbered, printed steps. Each one calls a previously-defined pure function. There's no new <em>logic</em> here, only <em>sequencing</em>. That's intentional.</p>
<p><strong>Best practice:</strong> log progress clearly at each stage. When a scheduled job fails at 3am, a clear step-by-step log is the difference between a two-minute diagnosis and an hour of guessing. A good option is to use a <a href="https://www.geeksforgeeks.org/python/logging-in-python/">logger</a>. Here, this tutorial sticks with classic console printing (<code>print(f" 1. Converting types...")</code>).</p>
<h2 id="heading-part-6-the-load-step">Part 6: The Load Step</h2>
<h3 id="heading-design-logic">Design Logic</h3>
<p><strong>Load</strong> is the final stage: take the processed data and move it to its destination. Typical destinations include data warehouses, data lakes, and databases.</p>
<p>Before publishing, the pipeline prepares two things: the output folder itself, and a metadata file that describes the dataset.</p>
<h4 id="heading-code-level-walkthrough">Code-level walkthrough</h4>
<p><code>create_output_dir()</code> makes sure an output folder exists, and creates one if it doesn't:</p>
<ul>
<li><p>If <code>use_mock</code> is <code>True</code>, it uses <code>KAGGLE_CONFIG.mock_output_dir</code>, the directory for fake data.</p>
</li>
<li><p>Otherwise, it uses the real output directory set in <code>KAGGLE_CONFIG.output_dir</code>.</p>
</li>
</ul>
<pre><code class="language-python">def create_output_dir(use_mock: bool = False) -&gt; None:
    """Create output directory (and parents) if it does not already exist."""
    output_dir = KAGGLE_CONFIG.mock_output_dir if use_mock else KAGGLE_CONFIG.output_dir
    output_dir.mkdir(parents=True, exist_ok=True)
    # exist_ok=True: idempotent, safe to call multiple times
</code></pre>
<p><code>exist_ok=True</code> is a small but important detail. Without it, <a href="https://docs.python.org/3/library/pathlib.html#pathlib.Path.mkdir"><code>Path.mkdir()</code></a> raises <code>FileExistsError</code> if the directory already exists, which it will on every run after the first.</p>
<p>Setting <code>exist_ok=True</code> makes directory creation <strong>idempotent</strong>: calling it 100 times has the same effect as calling it once. On the filesystem, this is the "safe to re-run" idea behind the deduplication logic from Part 5.</p>
<p>The Kaggle API follows the <a href="https://frictionlessdata.io/specs/data-package/">Data Package specification</a>. It <a href="https://github.com/Kaggle/kaggle-cli/wiki/Dataset-Metadata/bc2684f533cd40afae28210d8f6e62b88d793d62">requires</a> a descriptive <code>dataset-metadata.json</code> file alongside the CSV. This matters for the search and discoverability of the <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">Paris flood dataset</a>:</p>
<pre><code class="language-python">def create_metadata(df: pd.DataFrame, config: KaggleConfig) -&gt; Dict:
    """Generate Kaggle dataset metadata from DataFrame and config."""
    if df.empty or "record_date" not in df.columns:
        first_date = "unknown"
        last_date = "unknown"
    else:
        first_date = df["record_date"].min().strftime("%Y-%m-%d")
        last_date = df["record_date"].max().strftime("%Y-%m-%d")

    return {
        "title": config.title,
        "id": config.dataset_slug,
        "licenses": [{"name": config.license_name}],
        "keywords": config.keywords,
        "temporalCoverage": {"startDate": first_date, "endDate": last_date},
        "geospatialCoverage": config.geospatial_coverage,
        "updateFrequency": config.update_frequency,
    }
</code></pre>
<p>Metadata comes from the <code>KaggleConfig</code> dataclass. Note that <code>temporalCoverage</code> is computed <em>from the data itself</em> (<code>df["record_date"].min()</code>/<code>.max()</code>) rather than hardcoded. Every time the pipeline runs, the metadata's date range automatically reflects reality, with zero manual bookkeeping.</p>
<p>Finally, <code>publish_to_kaggle()</code> uploads the updated dataset to Kaggle. It calls out to the <a href="https://github.com/Kaggle/kaggle-api">Kaggle CLI</a> as an external command.</p>
<p>What it does:</p>
<ol>
<li><p>Gets the current time and turns it into a text label like <code>Weekly update: 2026-07-13 14:30:00</code></p>
</li>
<li><p>Builds a Kaggle command that says:</p>
<ul>
<li><p>publish a new dataset version</p>
</li>
<li><p>use files from <code>KAGGLE_CONFIG.output_dir</code></p>
</li>
<li><p>attach the message</p>
</li>
<li><p>zip the directory contents</p>
</li>
</ul>
</li>
<li><p>Prints the command so you can see what will run</p>
</li>
<li><p>Runs the command with <code>subprocess.run(...)</code></p>
</li>
</ol>
<p>If the upload works:</p>
<ul>
<li>it prints <code>Successfully published to Kaggle.</code></li>
</ul>
<p>If it fails:</p>
<ul>
<li>it prints the error and then raises the error again so the failure isn't hidden.</li>
</ul>
<pre><code class="language-python">def publish_to_kaggle() -&gt; None:
    """Publish the updated dataset to Kaggle using the Kaggle CLI."""
    timestamp = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")
    message = f"Weekly update: {timestamp}"

    cmd = [
        "kaggle", "datasets", "version",
        "-p", str(KAGGLE_CONFIG.output_dir),
        "-m", message,
        "--dir-mode", "zip",
    ]

    print("Publishing to Kaggle...")
    print("Command:", " ".join(cmd))

    try:
        subprocess.run(cmd, check=True)
        print("Successfully published to Kaggle.")
    except subprocess.CalledProcessError as e:
        print(f"Error publishing to Kaggle: {e}")
        raise
</code></pre>
<p><code>publish_to_kaggle</code> calls the Kaggle CLI (Command Line Interface) through Python's <a href="https://docs.python.org/3/library/subprocess.html"><code>subprocess</code></a> module. This is the standard way to run an external command-line tool from a Python pipeline.</p>
<p>Two choices here are worth adopting as general habits, not just for Kaggle:</p>
<ul>
<li><p><code>cmd</code> <strong>is built as a</strong> <code>list</code><strong>, never as a concatenated string.</strong> Pass a list of arguments to <code>subprocess.run</code> skips invoking a shell entirely. That sidesteps <a href="https://portswigger.net/web-security/os-command-injection">shell-injection</a> risks and correctly handles arguments containing spaces or special characters. See the <a href="https://docs.python.org/3/library/subprocess.html#security-considerations">official <code>subprocess</code> security considerations</a>.</p>
</li>
<li><p><code>check=True</code> means that if the Kaggle CLI fails, by exiting with a non-zero status, <code>subprocess.run</code> raises <code>CalledProcessError</code> instead of silently returning. Without <code>check=True</code>, a failed publish would look exactly like a successful one to the rest of the code.</p>
</li>
</ul>
<h2 id="heading-part-7-assemble-the-full-pipeline">Part 7: Assemble the Full Pipeline</h2>
<h3 id="heading-the-global-rehearsal-mock-mode">The Global Rehearsal (Mock Mode)</h3>
<p>The whole pipeline is built and tested in isolation. It's time to wire the blocks into one linear function. First, run it entirely against mock data. Nothing gets published this way. For Demonstration purposes, a CSV filled with artificial data will land in a local <code>mock_output</code> folder.</p>
<pre><code class="language-python">def run_etl_pipeline(use_mock: bool = True) -&gt; pd.DataFrame:
    """Run the complete ETL pipeline: Extract → Transform → Load to CSV."""

    # STEP 1: LOAD EXISTING DATA
    if use_mock:
        loaded_df = pd.DataFrame(mock_api_response)
        loaded_df['date_obs_elab'] = pd.to_datetime(loaded_df['date_obs_elab'])
        loaded_df = rename_to_english(loaded_df)
    else:
        loaded_df = load_csv(KAGGLE_CONFIG.input_csv)

    # STEP 2: DETERMINE UPDATE RANGE
    should_update, start_date = determine_update_range(loaded_df)
    if not should_update:
        return loaded_df   # already current: nothing more to do

    # STEP 3: EXTRACT (FETCH DATA)
    fetched_data = fetch_all_data(start_date, use_mock=use_mock)

    # STEP 4: TRANSFORM I - DEDUPLICATE
    deduped_fetched_data = remove_duplicates(loaded_df, fetched_data)

    # STEP 5: TRANSFORM II - COMBINE (MERGE WITH EXISTING)
    new_df_english = rename_to_english(deduped_fetched_data)
    merged_historical_and_new = pd.concat([loaded_df, new_df_english], ignore_index=True)

    # STEP 6: TRANSFORM III - POST-PROCESS
    processed_records = postprocess(merged_historical_and_new)

    # STEP 7: EXPORT (LOAD TO CSV)
    create_output_dir(use_mock=use_mock)
    output_path = KAGGLE_CONFIG.mock_output_filename if use_mock else KAGGLE_CONFIG.output_csv_path
    processed_records.to_csv(output_path, index=False, sep=",")

    return processed_records
</code></pre>
<p>Every one of the seven steps above corresponds to a function you already built, and tested earlier in this tutorial. <strong>To Assemble them is almost mechanical.</strong> That's always the payoff of composing small, single-responsibility functions:</p>
<pre><code class="language-python">loaded_df                 = load_csv(...)                          # 1. load existing data
should_update, start_date = determine_update_range(...)            # 2. check what's needed
fetched_data               = fetch_all_data(start_date)             # 3. fetch new records
deduped_fetched_data       = remove_duplicates(existing, new_raw)   # 4. deduplicate
merged_historical_and_new  = pd.concat([existing, new_clean])       # 5. merge
processed_records          = postprocess(merged_historical_and_new) # 6. postprocess
processed_records.to_csv(...)                                       # 7. save
write_metadata() + publish_to_kaggle()                               # 8. publish
</code></pre>
<p>Each line's intent is obvious just from reading it left to right. That is exactly the point of good decomposition.</p>
<h3 id="heading-main-pipeline-orchestration"><code>main()</code>: Pipeline Orchestration</h3>
<p><code>main()</code> is the <a href="https://docs.python.org/en/3/library/__main__.html">conductor</a>. It wires every previously-built component together in the right order, just like <code>postprocess()</code> did one level down.</p>
<pre><code class="language-python">def main() -&gt; None:
    """Execute the full Paris Flood Monitoring ETL pipeline.

    EXTRACT
    1. Load the existing dataset from the Kaggle input mount
    2. Determine whether an update is needed (and from what date)
    3. Fetch new data from the Hub'Eau API

    TRANSFORM
    4. Deduplicate against the existing dataset
    5. Combine and post-process (type parsing, translation, derived cols...)

    LOAD
    6. Write the updated CSV and metadata file
    7. Publish the new dataset version to Kaggle

    Exits early if the dataset is already up to date.
    """
    final_dataset = run_etl_pipeline(use_mock=False)
    final_dataset.to_csv(KAGGLE_CONFIG.output_csv_path, index=False)
    write_metadata(final_dataset)

    print("\n[FINAL STEP] Publishing to Kaggle...")
    publish_to_kaggle()

    print("Running post-run validation")
    validate_and_analyze(final_dataset)


if __name__ == "__main__":
    main()
</code></pre>
<h3 id="heading-the-if-name-main-guard">The <code>if __name__ == "__main__":</code> Guard</h3>
<pre><code class="language-python">if __name__ == "__main__":
    main()
</code></pre>
<p>This is one of the most common idioms in Python, and it's worth understanding exactly what it does. Per the <a href="https://docs.python.org/3/library/__main__.html">official Python documentation</a> on <code>__main__</code>:</p>
<ul>
<li><p>Run the file directly (<code>python script.py</code>)</p>
<ul>
<li><p>→ the special variable <code>__name__</code> is set to <code>"__main__"</code></p>
</li>
<li><p>→ the condition is <code>True</code></p>
</li>
<li><p>→ <code>main()</code> executes.</p>
</li>
</ul>
</li>
<li><p><strong>Import</strong> the file as a module elsewhere (<code>import script</code>)</p>
<ul>
<li><p>→ <code>__name__</code> is set to the module's name instead</p>
</li>
<li><p>→ the condition is <code>False</code></p>
</li>
<li><p>→ <code>main()</code> does <strong>not</strong> run automatically.</p>
</li>
</ul>
</li>
</ul>
<p><strong>Best practice:</strong> always guard your entry point this way. It makes the module safely <strong>importable</strong> for testing individual functions, or for reuse in another script, without triggering the full pipeline (including a real publish to Kaggle!) just by importing it.</p>
<h2 id="heading-part-8-go-live-and-switch-to-the-real-api">Part 8: Go Live and Switch to the Real API</h2>
<p>Everything above runs safely against mock data. To point the pipeline at the real Hub'Eau API instead:</p>
<ol>
<li><p>Note the <code>use_mock=False</code> setting above. It threads through <code>APIConfig.use_mock</code>, and the calls to <code>run_etl_pipeline(use_mock=False)</code> and <code>main()</code>.</p>
</li>
<li><p>Make sure <code>KAGGLE_CONFIG.dataset_slug</code> and <code>input_csv</code> point at <strong>your own</strong> Kaggle dataset copy before you use it. You can only publish new versions of a dataset you own. Fork both the <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">notebook</a> and the <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">dataset</a>, then update the slug in the <a href="#heading-part-3-manage-configuration-with-dataclasses"><code>KaggleConfig</code></a> section.</p>
</li>
<li><p>Install and authenticate the <a href="https://github.com/Kaggle/kaggle-api">Kaggle CLI</a> if you plan to call <code>publish_to_kaggle()</code> outside of a Google Colab or Kaggle notebook.</p>
</li>
</ol>
<p>Everything else needs <strong>zero changes</strong>.</p>
<h3 id="heading-post-run-validation">Post-Run Validation</h3>
<p><code>validate_and_analyze()</code> closes the loop, and it's more than just a nice-to-have. It's a welcome sanity check that runs <em>after</em> the pipeline finishes. The output is a human-readable report covering shape, data types, null counts, summary statistics on <code>water_level_mm</code>, how many flood-alert records turned up, the temporal coverage and per-station record counts.</p>
<p>It doesn't change any data. It exists so that whoever, or whatever monitoring system, reads the run's log output can immediately see whether this week's numbers look sane. No need to analyze the CSV by hand. Try it!</p>
<h2 id="heading-summary">Summary</h2>
<p>You've just built a <em>complete</em> ETL pipeline that you can run again and again without breaking anything. The skeleton is here, and that same pattern repeats for almost any scheduled data job: swap out the API, the field mapping, and the destination.</p>
<p>For more tutorials like this one, check out my <a href="https://github.com/hyperphantasia">GitHub</a> or <a href="https://kaggle.com/grimespoint">Kaggle profile</a>.</p>
<p>Thanks for reading!</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://hubeau.eaufrance.fr/">Hub'Eau API</a>: France's official open water-data platform</p>
</li>
<li><p>The <a href="https://www.kaggle.com/code/grimespoint/paris-flood-dataset-weekly-updater">updater <code>.py</code> script</a> runs live once a week and updates the original <a href="https://www.kaggle.com/datasets/grimespoint/paris-flood-dataset">dataset</a>.</p>
</li>
<li><p>The source dataset generator is available on <a href="https://github.com/hyperphantasia/paris-flood-dataset">GitHub</a>.</p>
</li>
<li><p>The complete <a href="https://www.kaggle.com/code/grimespoint/data-engineering-with-python-etl-pipeline">Jupyter notebook</a> to follow this tutorial and code all along (<a href="https://github.com/hyperphantasia/kaggle/blob/main/notebook_archive/data-engineering-with-python-etl-pipeline.ipynb">backup available</a>).</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Programmatic Advertising Works ]]>
                </title>
                <description>
                    <![CDATA[ Most tutorials on programmatic advertising stop at the web banner. That's a shame, because the idea gets far more interesting once you follow it off the screen and into the physical world. If you've n ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-programmatic-advertising-works/</link>
                <guid isPermaLink="false">6a614617166ef0e401b18d80</guid>
                
                    <category>
                        <![CDATA[ Advertising ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 22:37:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ead3788a-52c2-4821-b5c9-1c626c50d375.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most tutorials on <a href="https://advertising.amazon.com/blog/programmatic-advertising#1">programmatic advertising</a> stop at the web banner. That's a shame, because the idea gets far more interesting once you follow it off the screen and into the physical world.</p>
<p>If you've never worked in advertising, don't worry. You don't need any ad industry background to follow along. If you can read basic Python, you have everything you need.</p>
<p>The advertising part is just the setting. The real subject is a skill you'll use everywhere: taking a messy slice of the real world and turning it into data that software can act on.</p>
<p>In this article, you'll learn what programmatic advertising is and why it worked so well on the web. You'll see why bringing it to a billboard is really a data modeling problem, and you'll build small Python models for each step.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-what-is-programmatic-advertising">What Is Programmatic Advertising?</a></p>
</li>
<li><p><a href="#heading-why-the-web-made-programmatic-easy">Why the Web Made Programmatic Easy</a></p>
</li>
<li><p><a href="#heading-the-billboard-problem">The Billboard Problem</a></p>
</li>
<li><p><a href="#heading-step-1-resolve-entities">Step 1: Resolve Entities</a></p>
</li>
<li><p><a href="#heading-step-2-model-location-as-computed-context">Step 2: Model Location as Computed Context</a></p>
</li>
<li><p><a href="#heading-step-3-represent-availability-as-a-schedule-not-a-boolean">Step 3: Represent Availability as a Schedule, Not a Boolean</a></p>
</li>
<li><p><a href="#heading-step-4-express-price-as-a-function-not-a-number">Step 4: Express Price as a Function, Not a Number</a></p>
</li>
<li><p><a href="#heading-putting-it-together">Putting It Together</a></p>
</li>
<li><p><a href="#heading-an-exercise-to-build-the-intuition">An Exercise to Build the Intuition</a></p>
</li>
<li><p><a href="#heading-the-takeaway">The Takeaway</a></p>
</li>
</ul>
<h2 id="heading-what-is-programmatic-advertising">What Is Programmatic Advertising?</h2>
<p>Advertising has two sides. A publisher, such as a news site, has ad space to fill. And an advertiser, such as a shoe brand, wants to fill it. For decades, connecting the two meant phone calls, emails, and paperwork.</p>
<p>Programmatic advertising replaces that manual process with software. No human negotiates the placement. A system looks at an ad slot and decides, in milliseconds, whether to buy it and at what price.</p>
<p>Here's how it works when you open a web page. The page tells an ad exchange that a slot is open, along with some context about the page and the viewer. Advertisers' systems bid on the slot in a live auction. The winning ad appears before the page finishes loading. This process is called <a href="https://en.wikipedia.org/wiki/Real-time_bidding">real-time bidding</a>, and it happens billions of times a day.</p>
<p>Two quick terms will help. Inventory means the ad space a seller has to offer. An impression means one view of an ad by one person.</p>
<p>On the web, this model arrived fast and felt almost effortless. It's worth understanding why.</p>
<h2 id="heading-why-the-web-made-programmatic-easy">Why the Web Made Programmatic Easy</h2>
<p>The web made programmatic easy by accident. Every web ad slot came pre-structured. It had an address, meaning a URL and a position on the page. It sat inside a document with a known shape, the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model">Document Object Model</a>. And it could report back whether an ad had loaded and been seen.</p>
<p>The whole environment was machine-readable from birth, because machines were already serving it. When automated buying showed up, it had a clean surface to work against. The medium had already done the hard modeling work.</p>
<p>Keep that in mind. It's the key to the whole lesson.</p>
<h2 id="heading-the-billboard-problem">The Billboard Problem</h2>
<p>Now point the same idea at a billboard. None of that convenient structure exists.</p>
<p>A physical sign doesn't announce where it is or which way it faces. It doesn't say what it costs this week or whether it's even available. For most of its history, that information lived in rate cards and salespeople's memories, not in anything a program could query.</p>
<p>Here's the insight. Out-of-home advertising, the industry term for billboards, transit posters, and public screens, didn't lag the web because it was a weaker medium. It lagged because it had no machine-readable interface. The audience was always there. The structured data was not.</p>
<p>So the real work of bringing programmatic to the physical world isn't clever bidding logic. It's data modeling.</p>
<p>Let's make that concrete. There are four steps. Each one maps to a pattern you'll see in many other engineering problems.</p>
<h2 id="heading-step-1-resolve-entities">Step 1: Resolve Entities</h2>
<p>The same billboard often shows up in several vendors' datasets. Each vendor gives it a different name and slightly different coordinates. Before you can do anything else, one physical object has to become one record. This is known as <a href="https://en.wikipedia.org/wiki/Record_linkage">record linkage</a>, here with a geographic twist.</p>
<p>A simple approach: treat two records as the same panel if they sit close together and share a similar name. To measure the distance between two coordinates, you can use the <a href="https://en.wikipedia.org/wiki/Haversine_formula">haversine formula</a>.</p>
<pre><code class="language-python">from math import radians, sin, cos, asin, sqrt

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

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

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

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

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

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

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

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

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

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

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

print(price_for(base_rate=3000, start=date(2026, 12, 1),
                weeks_out=19, occupancy=0.85))  # 4995.0
print(price_for(base_rate=3000, start=date(2026, 8, 1),
                weeks_out=2, occupancy=0.40))   # 4200.0
</code></pre>
<p>Once price is a function, software can compare thousands of panels and dates instantly. That's exactly what automated buying needs.</p>
<h2 id="heading-putting-it-together">Putting It Together</h2>
<p>You now have resolved entities, computed audience, live availability, and dynamic pricing. With that in place, <a href="https://www.adquick.com/guides/programmatic-dooh">programmatic digital out of home</a> advertising works in the physical world the same way it does online. Software can plan a campaign across physical screens, buy them automatically, adjust in near real time, and measure the result.</p>
<p>It was never really about the web. It was about whether the inventory had a schema, meaning a defined structure that tells software what each piece of data is. Once you give the physical world a schema, the automation follows.</p>
<h2 id="heading-an-exercise-to-build-the-intuition">An Exercise to Build the Intuition</h2>
<p>You don't need special access to practice this. Take any messy, location-based public dataset and rehearse the four steps. A good source is <a href="https://www.openstreetmap.org/">OpenStreetMap</a>, which offers free data on millions of real-world places.</p>
<p>First, resolve duplicates by merging records that describe the same real-world thing. Second, enrich each point with computed context instead of treating the raw record as complete. Third, model one field as state that changes over time rather than a fixed value. Fourth, wrap the result in a clean interface, such as a class or a small API, that something else could query.</p>
<p>You won't have built an ad platform, but you'll have practiced the exact skills that let automated buying reach a new field.</p>
<h2 id="heading-the-takeaway">The Takeaway</h2>
<p>A good abstraction survives contact with the real world.</p>
<p>When you first learn a concept in a tidy setting, it's easy to think the tidiness is part of the concept. It rarely is. You only find out how general an idea is when you drag it somewhere messy and watch it still work.</p>
<p>Programmatic buying, moved off the screen and onto a wall by a road, is one of the cleaner proofs of that. Keep an eye out for the same pattern elsewhere: a rich domain waiting on nothing but a schema.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build and Schedule Local AI Assistants for Daily Tasks ]]>
                </title>
                <description>
                    <![CDATA[ Most AI agents are reactive as they wait for us to ask something. In this tutorial, I'll show you how to build local AI assistants that run on a schedule, handle the tasks you care about, and generate ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-and-schedule-local-ai-assistants-for-daily-tasks/</link>
                <guid isPermaLink="false">6a555a585f978e5aa7071985</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai-agent ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ollama ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cron ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI assistant ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #qwen ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Darsh Shah ]]>
                </dc:creator>
                <pubDate>Mon, 13 Jul 2026 21:36:24 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/67ad144a-050e-4d98-a7c3-9f0a2c9b5648.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most AI agents are reactive as they wait for us to ask something. In this tutorial, I'll show you how to build local AI assistants that run on a schedule, handle the tasks you care about, and generate daily digests for it. Each Assistant is an AI agent and the goal is to automate repetitive work with a cron-driven setup that saves you time.</p>
<p>We'll use Python to create a simple local scheduler, a directory of agents, and Ollama running the model locally so you avoid per-call API charges and keep inference on your own machine.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-background">Background</a></p>
</li>
<li><p><a href="#heading-motivation-and-architecture">Motivation and architecture</a></p>
</li>
<li><p><a href="#heading-step-1-install-ollama-and-pull-the-model">Step 1: Install Ollama and pull the model</a></p>
</li>
<li><p><a href="#heading-step-2-install-python-dependencies">Step 2: Install Python dependencies</a></p>
</li>
<li><p><a href="#heading-step-3-define-the-agent-format">Step 3: Define the agent format</a></p>
</li>
<li><p><a href="#heading-step-4-create-the-agent-scheduler">Step 4: Create the Agent Scheduler</a></p>
</li>
<li><p><a href="#heading-step-5-add-three-real-agents">Step 5: Add three real agents</a></p>
<ul>
<li><p><a href="#heading-agent-1-googl-stock-check">Agent 1: GOOGL stock check</a></p>
</li>
<li><p><a href="#heading-agent-2-ai-news-digest">Agent 2: AI news digest</a></p>
</li>
<li><p><a href="#heading-agent-3-weather-brief">Agent 3: Weather brief</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-6-add-agent-scheduler-to-cron">Step 6: Add Agent Scheduler to cron</a></p>
<ul>
<li><p><a href="#heading-macos-and-linux">MacOS and Linux</a></p>
</li>
<li><p><a href="#heading-windows-with-task-scheduler">Windows with Task Scheduler</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-sample-output">Sample output</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-background">Background</h2>
<p>Many of us have AI agents that can perform useful tasks – but they still need to be triggered. What if you could build a system that runs every day, automatically invokes those agents, and delivers the results without any manual effort? As an example, Claude uses the <code>/loop</code> command to scheduling recurring tasks.</p>
<p>In this tutorial, we'll build a lightweight daily scheduler that does exactly that. Every day, it invokes three read-only AI agents on a schedule. The same pattern can be extended to automate virtually any recurring AI-powered workflow. The AI agent acts as your assistant to complete the task.</p>
<p>To follow this tutorial, you'll need Ollama installed on your machine. The example works on macOS, Windows, and Linux. I'm using a MacBook Pro with 32 GB of RAM, but you can run this on a lower-memory machine by choosing a smaller Qwen model from Ollama.</p>
<h2 id="heading-motivation-and-architecture">Motivation and Architecture</h2>
<p>The motivation behind this project is simple: I want AI agent workers to handle repetitive tasks for me. Instead of doing tasks manually, I can have specialized agents do the work automatically.</p>
<p>Another benefit of this approach is privacy and control. Since everything runs locally, the agents, prompts, and outputs remain on my machine. There's no need to rely on external automation platforms or send workflow data to third-party services.</p>
<p>The architecture is intentionally lightweight. A scheduler runs once a day and invokes a set of read-only AI agents.</p>
<p>Each agent is responsible for a single task: checking GOOGL stock performance, summarizing the latest AI news, and generating a weather brief. The agent scheduler executes them independently, collects their outputs, and stores the results as markdown file in outputs folder. As the needs grow, we can add more agents to the folder to create additional recurring workflows. The agent scheduler code won't change.</p>
<pre><code class="language-plaintext">project/
├── scheduler.py
├── outputs/
├── agents/
    ├── googl_stock.py
    ├── ai_news.py
    └── weather_brief.py
</code></pre>
<h2 id="heading-step-1-install-ollama-and-pull-the-model">Step 1: Install Ollama and Pull the Model</h2>
<p>First, install Ollama for your platform.</p>
<p>We'll use Qwen for the local model.</p>
<pre><code class="language-bash">ollama pull qwen3.5:4b
</code></pre>
<h2 id="heading-step-2-install-python-dependencies">Step 2: Install Python Dependencies</h2>
<p>Create a virtual environment and install the packages:</p>
<pre><code class="language-bash">python3 -m venv venv
source venv/bin/activate
pip install langchain langchain-ollama requests
</code></pre>
<p>It requires LangChain &gt;= 1.0.0</p>
<p>One of the example agents uses Ollama's hosted web search API for fresh AI news. That API requires an <a href="https://docs.ollama.com/api/authentication#api-keys">Ollama account</a> and an API key in <code>OLLAMA_API_KEY</code>.</p>
<p>Set the key like this:</p>
<pre><code class="language-bash">export OLLAMA_API_KEY="paste-key-here"
</code></pre>
<h2 id="heading-step-3-define-the-agent-format">Step 3: Define the Agent Format</h2>
<p>Every agent is a Python file in the <code>agents/</code> folder with two attributes:</p>
<ul>
<li><p><code>NAME</code></p>
</li>
<li><p><code>run()</code></p>
</li>
</ul>
<p><code>run()</code> takes no arguments and returns a string. Whatever it returns gets written to a timestamped Markdown file in <code>outputs/</code>.</p>
<p>Create the folder structure:</p>
<pre><code class="language-bash">mkdir -p agents outputs
touch agents/__init__.py
</code></pre>
<h2 id="heading-step-4-create-the-agent-scheduler">Step 4: Create the Agent Scheduler</h2>
<p>The agent scheduler does three small jobs:</p>
<ol>
<li><p>Loads every agent module from <code>agents/</code></p>
</li>
<li><p>Calls <code>run()</code> on each one</p>
</li>
<li><p>Saves the result to <code>outputs/</code></p>
</li>
</ol>
<p>That's the whole agent scheduler. There's no state file or per-agent scheduling logic. The OS scheduler decides when the agent scheduler fires, and the agent scheduler executes every agent each time and saves the output from the agents as markdown file in outputs/ folder.</p>
<p>To add more agents, simply add them to the agents/ folder. The agent scheduler doesn't need to change.</p>
<p>Save this as <code>scheduler.py</code>:</p>
<pre><code class="language-python">import importlib
from datetime import datetime
from pathlib import Path

# Folder that contains all agent files.
AGENTS_DIR = Path("agents")

# Folder where the output files will be written.
OUTPUTS_DIR = Path("outputs")


def load_agents():
    """Import every valid agent module from the agents/ folder."""
    agents = []

    # Look through all Python files in agents/
    for path in sorted(AGENTS_DIR.glob("*.py")):
        # Skip private helper files like __init__.py
        if path.name.startswith("_"):
            continue

        # Import the file as a Python module, e.g. agents.googl_stock
        module = importlib.import_module(f"agents.{path.stem}")

        # Only keep modules that define NAME and run()
        if hasattr(module, "NAME") and hasattr(module, "run"):
            agents.append(module)
        else:
            print(f"[skip] {path.name} (missing NAME or run)")

    return agents


def main():
    """Load all agents, run them, and save their outputs."""
    # Create the outputs/ folder if it doesn't exist yet.
    OUTPUTS_DIR.mkdir(exist_ok=True)

    # Run every agent we found.
    for agent in load_agents():
        print(f"[run]  {agent.NAME}")

        try:
            # Call the agent's run() function.
            output = agent.run()

            # Create a timestamped filename like:
            # outputs/weather-brief-2026-07-03_08-00-39.md
            timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
            out_path = OUTPUTS_DIR / f"{agent.NAME}-{timestamp}.md"

            # Write the returned text to disk.
            out_path.write_text(output)

            print(f"[ok]   {agent.NAME} -&gt; {out_path}")
        except Exception as e:
            # If one agent fails, log it and continue with the others.
            print(f"[fail] {agent.NAME}: {e}")


if __name__ == "__main__":
    main()
</code></pre>
<h2 id="heading-step-5-add-three-real-agents">Step 5: Add Three Real Agents</h2>
<p>Here are three simple, read-only agents.</p>
<h3 id="heading-agent-1-googl-stock-check">Agent 1: GOOGL Stock Check</h3>
<p>Save this as <code>agents/googl_stock.py</code>.</p>
<p>It fetches GOOGL's daily quote data, computes the change in Python, and asks the local model to turn that into a short summary.</p>
<pre><code class="language-python">import requests
from langchain.agents import create_agent
from langchain_ollama import ChatOllama

NAME = "googl-stock"


def fetch_googl():
    url = "https://query1.finance.yahoo.com/v8/finance/chart/GOOGL?interval=1d&amp;range=1d"
    r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
    r.raise_for_status()

    meta = r.json()["chart"]["result"][0]["meta"]
    price = meta["regularMarketPrice"]
    prev = meta["chartPreviousClose"]
    change = price - prev
    pct = (change / prev) * 100 if prev else 0

    return {
        "symbol": "GOOGL",
        "price": round(price, 2),
        "previous_close": round(prev, 2),
        "change": round(change, 2),
        "pct_change": round(pct, 2),
    }


def run():
    data = fetch_googl()

    agent = create_agent(
        model=ChatOllama(model="qwen3.5:4b", temperature=0),
        tools=[],
        system_prompt=(
            "You write short stock summaries. "
            "Given stock data, write 2 concise Markdown bullet points explaining "
            "the price move and whether it was an up or down day."
        ),
    )

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

    return (
        "# GOOGL Daily Summary\n\n"
        f"{result['messages'][-1].content}\n\n"
        f"**Raw data:** `{data}`\n"
    )
</code></pre>
<h3 id="heading-agent-2-ai-news-digest">Agent 2: AI News Digest</h3>
<p>Save this as <code>agents/ai_news.py</code>.</p>
<p>This agent uses Ollama's web search API to pull recent AI news results, then asks the local model to turn them into a short digest. The <code>OLLAMA_API_KEY</code>is the same one that is used for my <a href="https://www.freecodecamp.org/news/build-a-personal-ai-web-research-agent-with-ollama-and-qwen/">Personal Web Research AI Agent</a> tutorial.</p>
<pre><code class="language-python">import os
import requests
from langchain.agents import create_agent
from langchain_ollama import ChatOllama

NAME = "ai-news"


def search_news():
    r = requests.post(
        "https://ollama.com/api/web_search",
        headers={"Authorization": f"Bearer {os.getenv('OLLAMA_API_KEY')}"},
        json={"query": "latest AI news", "max_results": 5},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["results"]


def run():
    results = search_news()

    agent = create_agent(
        model=ChatOllama(model="qwen3.5:4b", temperature=0),
        tools=[],
        system_prompt=(
            "You write short AI news digests. "
            "Given search results, produce 3-5 Markdown bullet points. "
            "Each bullet should summarize one important story and end with its source URL."
        ),
    )

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

    return f"# Daily AI News Digest\n\n{result['messages'][-1].content}\n"
</code></pre>
<h3 id="heading-agent-3-weather-brief">Agent 3: Weather Brief</h3>
<p>Save this as <code>agents/weather_brief.py</code>.</p>
<pre><code class="language-python">import requests
from langchain.agents import create_agent
from langchain_ollama import ChatOllama

NAME = "weather-brief"


def fetch_weather():
    r = requests.get("https://wttr.in/New+York?format=j1", timeout=15)
    r.raise_for_status()

    current = r.json()["current_condition"][0]
    return {
        "temp_f": current["temp_F"],
        "feels_like_f": current["FeelsLikeF"],
        "humidity": current["humidity"],
        "wind_mph": current["windspeedMiles"],
        "description": current["weatherDesc"][0]["value"],
    }


def run():
    weather = fetch_weather()

    agent = create_agent(
        model=ChatOllama(model="qwen3.5:4b", temperature=0),
        tools=[],
        system_prompt=(
            "You write short weather briefs. "
            "Given current weather data, write 2 concise Markdown bullet points "
            "summarizing the conditions in plain English."
        ),
    )

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

    return f"# Daily Weather Brief\n\n{result['messages'][-1].content}\n"
</code></pre>
<h2 id="heading-step-6-add-agent-scheduler-to-cron">Step 6: Add Agent Scheduler to cron</h2>
<p>The Agent Scheduler is designed to be triggered by your OS scheduler. Every time it runs, it executes all agents in the agents/ folder.</p>
<p>We need to use the full path to Python inside the virtual environment. Schedulers usually don't inherit your shell's <code>PATH</code>, so a bare <code>python</code> often won't work the way you expect.</p>
<h3 id="heading-macos-and-linux">MacOS and Linux</h3>
<p>On macOS, you can use either <code>launchd</code> or <code>cron</code>. <code>launchd</code> is the macOS-native scheduler, but for this tutorial, I'm using <code>cron</code> as it works for Linux as well.</p>
<p>Create a run_scheduler.sh script and put it alongside your code. Paste Ollama API key in placeholder.</p>
<pre><code class="language-plaintext">#!/bin/bash

export OLLAMA_API_KEY="&lt;key&gt;"
cd /full/path/to/project
/full/path/to/project/venv/bin/python3 scheduler.py &gt;&gt; runner.log 2&gt;&amp;1
</code></pre>
<p>Make it executable by doing <code>chmod +x run_scheduler.sh</code> in the terminal. You can test it by doing <code>./run_scheduler.sh</code> in your terminal.</p>
<p>Open your crontab:</p>
<pre><code class="language-bash">crontab -e
</code></pre>
<p>Add this line:</p>
<pre><code class="language-bash">0 8 * * * /full/path/to/project/run_scheduler.sh
</code></pre>
<p>This runs the scheduler.py every day at 8:00 AM. The <code>runner.log</code> captures both normal output and errors.</p>
<p>One caveat: if your machine is asleep when the cron job is supposed to run, that invocation is usually just missed.</p>
<h3 id="heading-windows-with-task-scheduler">Windows with Task Scheduler</h3>
<p>From PowerShell:</p>
<pre><code class="language-powershell">schtasks /Create /SC DAILY /TN "AI Runner" /TR "C:\path\to\venv\Scripts\python.exe C:\path\to\scheduler.py" /ST 08:00
</code></pre>
<p>Set the working directory to your project folder in the task settings so <code>agents/</code> and <code>outputs/</code> resolve correctly.</p>
<h2 id="heading-sample-output">Sample Output</h2>
<p>Run the scheduler manually first:</p>
<pre><code class="language-bash">python scheduler.py
</code></pre>
<p>Here's what one run looks like:</p>
<pre><code class="language-text">$ python scheduler.py
[run]  ai-news
[ok]   ai-news -&gt; outputs/ai-news-2026-07-05_17-52-12.md
[run]  googl-stock
[ok]   googl-stock -&gt; outputs/googl-stock-2026-07-05_17-53-18.md
[run]  weather-brief
[ok]   weather-brief -&gt; outputs/weather-brief-2026-07-05_17-53-54.md
</code></pre>
<p>The output is stored in <code>outputs/</code> folder. The output from each agent is shown below:</p>
<pre><code class="language-plaintext">outputs % ls
ai-news-2026-07-05_17-52-12.md
googl-stock-2026-07-05_17-53-18.md	
weather-brief-2026-07-05_17-53-54.md
</code></pre>
<pre><code class="language-plaintext">$cat googl-stock-2026-07-05_17-53-18.md 
# GOOGL Daily Summary

*   GOOGL closed at $359.91, down $1.30 (0.36%) from the previous close of $361.21.
*   This marks a down day for the stock.

**Raw data:** `{'symbol': 'GOOGL', 'price': 359.91, 'previous_close': 361.21, 'change': -1.3, 'pct_change': -0.36}`
</code></pre>
<pre><code class="language-plaintext">$cat weather-brief-2026-07-05_17-53-54.md 
# Daily Weather Brief

*   It's 77°F, feeling like 80°F.
*   Partly cloudy with 9 mph winds.
</code></pre>
<pre><code class="language-plaintext">cat ai-news-2026-07-05_17-52-12.md 
# Daily AI News Digest

*   After spooking the Trump administration into safety testing, Anthropic's Fable 5 and Mythos 5 models have received global release with export curbs lifted.
    https://arstechnica.com/tech-policy/2026/07/after-spooking-trump-into-safety-testing-anthropic-ai-models-get-global-release/
*   OpenAI has previewed three GPT-5.6 models (Sol, Terra, and Luna) with limited availability restricted to U.S. government-approved organizations.
    https://www.deeplearning.ai/the-batch/gpt-5-6-lands-in-limbo
...
</code></pre>
<p>Before trusting the results, spot-check them. Smaller local models still hallucinate, and unattended agents amplify small mistakes because no one is there to catch them in real time.</p>
<p>To run it more frequently for testing, you can update the cron from <code>* 8 * * *</code> to <code>*/10 * * * *</code> so that it runs every 10 mins. Once you're satisfied with the setup and results, you can revert the cron to 8:00 AM everyday by setting it to <code>* 8 * * *</code>.</p>
<p>If you want to extend the setup, a few good next steps would be adding new agents, trying out different schedules, or setting up notifications when the agent scheduler finishes.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a small local AI agent scheduler that executes multiple agents from a folder. Each agent is just a Python file that calls an LLM and executes a task. The agent scheduler loads them, runs them, and writes the outputs to disk.</p>
<p>That gives you a nice workflow for lightweight local automation. Adding a new agent just involves dropping a file into <code>agents/</code>, not editing scheduler config again. The model runs locally through Ollama, the outputs stay on your machine, and there aren't LLM API costs.</p>
<p>From here, you can add your own agents. Perhaps a summary of yesterday's Git commits or a tool to watch for new releases of a repo you care about. Anything that you'd want waiting for you in the morning but that you don't want to check yourself. Happy tinkering!</p>
<p>If you enjoyed this tutorial, you can find more of my writing on my <a href="https://darshshah.org/blog/">blog</a> (recent posts include system design paper series), my work on my <a href="https://darshshah.org/">personal website</a>, and updates on <a href="https://www.linkedin.com/in/darshs">LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Agent That Runs its Own LLM Experiments with autoresearch ]]>
                </title>
                <description>
                    <![CDATA[ A few months ago, Andrej Karpathy released autoresearch. It's an open-source Python tool that lets an AI agent run experiments on one GPU while you sit back and wait for the results. Lately I've still ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-ai-agent-that-runs-its-own-llm-experiments-with-autoresearch/</link>
                <guid isPermaLink="false">6a42a24e2a8a54195ace1aab</guid>
                
                    <category>
                        <![CDATA[ ai-agent ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ ishaan gupta ]]>
                </dc:creator>
                <pubDate>Mon, 29 Jun 2026 16:50:22 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4f910471-5f78-41c0-a30e-7630737bbb74.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A few months ago, Andrej Karpathy released <a href="https://github.com/karpathy/autoresearch"><strong>autoresearch</strong></a>. It's an open-source Python tool that lets an AI agent run experiments on one GPU while you sit back and wait for the results.</p>
<p>Lately I've still seen folks on Twitter arguing about whether AI agents can build their <em>“million dollar idea”</em> or something about <em>Openclaw</em>. But here's a repo that lets you hand an agent a real GPT training setup and ask it to do the research itself.</p>
<p>Basically it edits the code, trains, reads the loss, makes a decision about the result, and repeats this process. And all this happens while you sleep, or dig into something else. And surprisingly, it does actually work.</p>
<p>On a depth-12 nanochat baseline (more on what "depth" means later), Karpathy left it running for about two days. Over roughly 700 experiments, the agent found about 20 changes that genuinely improved the model, and those changes stacked on top of each other.</p>
<p>In this article, I'll walk through what autoresearch is, why the way it measures success is the whole trick, what each file in the repo actually does, what the agent tends to discover, and a step-by-step guide to running it yourself. By the end you should be able to point an agent at your own GPU and let it run.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-autoresearch">What is autoresearch?</a></p>
</li>
<li><p><a href="#heading-why-this-matters">Why This Matters</a></p>
</li>
<li><p><a href="#heading-what-exactly-is-valbpb">What Exactly is <code>val_bpb</code>?</a></p>
</li>
<li><p><a href="#heading-what-the-agent-actually-finds">What the Agent Actually&nbsp;Finds</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>This article is a complete walkthrough of this repo. The goal is that by the end, you'll understand what autoresearch is and how you can run it on your own machine.</p>
<p>No prior ML research experience required, but if you have it then the deeper sections I wrote will be more meaningful to you. Just basic knowledge of GPU, VRAM and GPUs like H100/A100/4090 would suffice, but don't worry i have quoted the text below explaining every term i think a beginner needs to understand.</p>
<h2 id="heading-what-is-autoresearch">What is autoresearch?</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6a0065c6e3eebc2e20691ad8/4d4413c5-7264-49b0-bcb0-1cf8b7e763f7.png" alt="flowchart of the autoresearch loop" style="display:block;margin:0 auto" width="1600" height="967" loading="lazy">

<p>Simply put, autoresearch is just one specific idea executed cleanly. You take a small but real LLM training setup, put it in a single Python file, and let an AI agent edit that file.</p>
<p>The agent runs the file and reads the loss. When you train a language model, "loss" is just a single number that scores how badly the model is predicting the next chunk of text. A high number means it's guessing poorly, and a number close to zero means it's predicting almost perfectly.</p>
<p>Training is the process of nudging the model's millions of internal weights to push that number down. So when I say the agent "reads the loss," I mean it looks at that score to judge whether the change it just made helped or hurt.</p>
<p>Based on that score, the agent decides whether the change helped, and then either keeps the change or reverts it. Then it tries something else.</p>
<p>The flow runs top to bottom like this: A human (you) writes the playbook (a Markdown file called <a href="http://program.md">program.md</a>), which spells out the rules. An AI agent reads that playbook and starts an experiment loop.</p>
<p>In each pass of the loop, the agent edits the training code with a new idea, trains for five minutes, reads the resulting score, decides whether to keep or undo the change, and writes the outcome to a results file. Then it loops back and tries the next idea.</p>
<p>It does this on its own, around twelve times an hour. So a full night of sleep buys you roughly a hundred experiments and, with luck, a noticeably better model by morning.</p>
<p>The repo is laid out so the agent has exactly one knob to turn. It can't install new packages or change how the data is loaded or how the loss is measured. All of that is locked down on purpose. The only file the agent edits is <code>train.py</code> which consists of the model architecture, the optimizer, the batch size, the learning rate, and the structure of the training loop itself.</p>
<p>The reason this design works is the same reason a controlled experiment in any field works. If the data, the metric, and the budget are all fixed, then any change in the result must be coming from the change the agent made. The agent is doing science the way a careful researcher would, only it doesn't get tired and doesn't need lunch.</p>
<h2 id="heading-why-this-matters">Why This Matters</h2>
<p>It's tempting to read this as just another agent demo. But it's not, and the reason is the metric. That metric is called val_bpb, short for validation bits per byte. It's a specific way of scoring how well the model predicts text it has never seen during training (the "validation" set).</p>
<p>I'll break down exactly how it's calculated in the next section, but the one-line version is that it measures, on average, how many bits of information the model needs to encode each byte of text. Lower is better: a lower val_bpb means the model is surprised less often by real text, which is the whole goal.</p>
<p>The reason Karpathy uses bits per byte rather than the raw training loss is that bits per byte doesn't change just because you changed the vocabulary, so two very different models can still be compared fairly. The "lower is better" part and the "vocabulary-independent" part are two separate properties. The metric happens to have both.</p>
<p>When I say a baseline model from this repo "lands around 1.00 bpb," I mean that if you run the default untouched training script for its 5 minutes, the model it produces scores roughly 1.00 on this metric when measured on the held-out validation text. That's your starting line.</p>
<p>From there, an improvement of 0.005 bpb (so a score of about 0.995) is a small but real win, the kind the agent finds often. An improvement of 0.05 (a score near 0.95) would be enormous, the kind of jump you'd usually only get from a much bigger model or a much longer training run. So the numbers look tiny, but on this scale, thousandths of a bit genuinely matter.</p>
<p>Here's why optimizing this particular number is a big deal. The agent isn't chasing some artificial leaderboard that researchers spent years gaming. It's pushing down the same kind of validation loss curve that every major language model has been trained against since GPT-2 in 2019.</p>
<p>A "loss curve" is just the plot of that score dropping over the course of training, and "the wave of LLMs since GPT-2" is shorthand for the fact that essentially all of the progress, from GPT-2 to today's frontier models, came from people finding ways to make that curve drop faster or lower for the same amount of compute. The agent is working on the exact same problem, just at a small, fast cheap scale.</p>
<p>And that's what makes the next part surprising. When the agent finds an improvement "here," I mean on the small depth-12 model it's allowed to edit. "Depth" is the number of transformer layers stacked in the model. depth-12 is a small model, and depth-24 is a bigger one with twice as many layers.</p>
<p>Karpathy took the roughly 20 tweaks the agent discovered on the small depth-12 model and applied them to the bigger depth-24 model. Being stacked cleanly means two things at once: the improvements were additive (turning on all 20 together gave you the sum of their individual gains, rather than cancelling each other out), and they transferred (gains found on the small model still showed up on the big one).</p>
<p>That's the signal that the agent found real insights about training, not lucky quirks that only help at one specific size. Stacked together, they cut Karpathy's "Time to GPT-2" benchmark from 2.02 hours to 1.80 hours, which is about an 11% speedup on code he'd already hand-tuned for a long time.</p>
<p>The other thing that's significant is the budget. Each experiment runs for exactly 5 minutes of wall-clock training time, no more, no less. That gives roughly 12 experiments per hour, or about 100 in a typical 8-hour sleep cycle.</p>
<h3 id="heading-exploring-the-repo">Exploring the Repo</h3>
<p>Now if you clone the repo, you get a small handful of files. Most of them are plumbing. Three of them are the heart of the system and the difference between them is who edits what.</p>
<p>Only three files matter, and they differ by who edits them.</p>
<ol>
<li><p><a href="http://train.py">train.py</a> is the file the agent edits. it holds the GPT model, the optimizer, and the training loop, and everything in it is fair game.</p>
</li>
<li><p><a href="http://prepare.py">prepare.py</a> is the fixed foundation that nobody edits during a run: it downloads the data, trains the tokenizer, and defines the metric.</p>
</li>
<li><p><a href="http://program.md">program.md</a> is the file you, the human, edit: it's the playbook of rules the agent follows.</p>
</li>
</ol>
<p>The remaining files (README.md, pyproject.toml, uv.lock, .gitignore, .python-version, the analysis.ipynb notebook, and the progress.png image) are plumbing and documentation that neither you nor the agent needs to touch during a run.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a0065c6e3eebc2e20691ad8/1a8acbf9-87a3-428e-9cc1-53aaee2adc91.png" alt="three main files that we need to understand" style="display:block;margin:0 auto" width="1600" height="752" loading="lazy">

<p>There are a few other files in the repo which don't need attention from you or the agent during a run.</p>
<h2 id="heading-what-exactly-is-valbpb">What Exactly is <code>val_bpb</code>?</h2>
<p>Before going further, it helps to understand what val_bpb is. If you've read other LLM articles, you have probably seen terms like <strong>“perplexity”</strong> or <strong>“cross-entropy loss”</strong> thrown around.</p>
<p>Bits per byte is like their cousin. When a language model predicts text, it assigns probabilities to what comes next. If the model is confident and right, it gets a low loss. If it's confident and wrong, it gets a high loss, a large penalty. Add up those penalties across all the text and you get the model's total loss. Lower is better, because a lower total means the model assigned high probability to the words that actually appeared.</p>
<p>Cross-entropy loss is the standard scoring function for training language models. For each token, the model assigns a probability to every possible next token and the loss is the negative logarithm of the probability it gave to the token that actually came next. Predict the right token confidently and the loss is near zero. Assign low probability to the correct token and the loss is large. The model's total loss is the average of this across all tokens.</p>
<p>Cross-entropy loss measures this in nats. A nat is the unit you get when that logarithm is taken in base e (the natural log) instead of base 2. It measures the same quantity of "surprise" on a different scale (one nat is about 1.44 bits). Dividing the loss by the natural log of 2 is what rescales nats into bits, which is the conversion bits per byte performs.</p>
<p>Bits per byte takes that loss and divides it by the number of bytes the text actually contains, then converts to log base 2. The result is a number that tells you, on average, how many bits of information the model needs to encode each byte of text.</p>
<p>A perfect model would need close to zero, while a random model would need around 8 bits per byte (since a byte has 8 bits).</p>
<p>The reason Karpathy chose bpb instead of plain cross-entropy is that bpb is <strong>vocabulary-size-independent</strong>. If the agent decides to change the tokenizer or the vocabulary, the cross-entropy loss would be completely different even for the same model quality. Bits per byte normalizes that out, so a depth-8 model with vocab 8192 and a depth-12 model with vocab 16384 are directly comparable.</p>
<p>The function that computes this, evaluate_bpb, lives in prepare.py, which the agent is never allowed to edit. It can only touch train.py. Because the metric's definition sits in a file the agent can't modify, it can't lower its score by quietly changing how the score is calculated. The scoring rule stays identical for every experiment, which is what makes the comparison honest.</p>
<h3 id="heading-the-5-minute-rule">The 5 Minute&nbsp;Rule</h3>
<p>There's one design choice in autoresearch that deserves its own section, because it's the choice that makes the whole thing work in practice. Every experiment runs for exactly 5 minutes of wall-clock training time regardless of what the agent is doing.</p>
<p>Wall-clock time means real elapsed time: what a clock on the wall measures, and not the number of training steps or tokens processed. 5 minutes of wall-clock time is 5 literal minutes regardless, of how much the model does in them.</p>
<p>If you trained for a fixed number of steps instead, the agent could “win” by making the model so small that it ripped through more steps than the baseline. If you trained for a fixed number of tokens, the agent could win by lowering the sequence length.</p>
<p>The agent isn't competing against another agent as we might think of it. Its only objective is to push val_bpb below the previous best score on this exact setup. So "winning" means producing a lower score, and the risk is that it lowers the score through a degenerate shortcut that games whichever budget you chose rather than a real efficiency gain. If you trained until convergence, the agent’s run would take wildly different amounts of time and you would never finish 100 experiments in a night.</p>
<p>A fixed wall clock budget cuts through all of this. The agent is forced to optimize for actual training efficiency on the actual hardware in front of it. If it makes the model slightly bigger but the per-step compute drops because of a smarter attention pattern, that's a real win. If it speeds up the per-step compute but the model now learns less per step, that shows up as a worse val_bpb. The two effects get netted out automatically in the end.</p>
<p>The H100 and A100 are NVIDIA datacenter GPUs and the RTX 4090 is a high-end consumer card. They differ sharply in speed and memory, and that's the whole point: in a fixed 5 minute budget, a faster card processes more data and reaches a lower val_bpb. So a score from one GPU can't be compared head-to-head with a score from another.</p>
<p>There's a tradeoff, though. Because the budget is wall-clock, the val_bpb you get on an H100 isn't directly comparable to the val_bpb you get on a 4090 or an A100. The system is designed to find the best model <strong>for your specific compute platform</strong> in 5 minutes, not to be a global benchmark.</p>
<p>If you want to compare across hardware, you would need to fix a different budget. For the autonomous research use case, this is exactly right.</p>
<p>Let’s get into each of the files in depth now.</p>
<h3 id="heading-1-preparepy">1. <code>prepare.py</code></h3>
<p>Nobody touches this file but everything depends on it. It mainly performs three jobs.</p>
<p>The first job is downloading data. The training corpus is ClimbMix-400B, a high-quality web dataset hosted on HuggingFace and shuffled into 6,543 parquet shards. By default <code>prepare.py</code> downloads only 10 of these (about a few gigabytes), which is plenty for running thousands of 5-minute experiments.</p>
<p>The very last shard is always downloaded and pinned as the validation set. That pinning matters, since every experiment (no matter what changes) evaluates on the exact same held-out data.</p>
<p>The second job is training a tokenizer. The repo uses <strong>rustbpe,</strong> a fast Rust implementation of byte-pair encoding, to learn a vocabulary of 8,192 tokens from a sample of the training data. The result is exported as a tiktoken-compatible encoding so it integrates cleanly with PyTorch downstream. There's also a small precomputed lookup table called <code>token_bytes.pt</code> that maps each token id to its UTF-8 byte length. This is what makes the bpb calculation honest.</p>
<p>The third job is providing utilities that <code>train.py</code> imports at runtime. The dataloader is the interesting one. It does what's called <strong>best-fit packing</strong>: every row in the batch starts with a special BOS (beginning of sequence) token and the loader fills the row by greedily picking documents that fit in the remaining space. Only when no document fits does it crop the shortest available document to fill the gap.</p>
<p>The result is 100% utilization with no padding. This is meaningfully faster than the naïve approach of just truncating long documents and padding short ones. The constants at the top of <code>prepare.py</code> are deliberately simple. Three numbers and a sequence length define the entire experimental contract.</p>
<p>If you run autoresearch on different hardware and want to compare results with a friend, the only thing both of you need to share is these constants. That's the whole point of putting them here and nowhere else.</p>
<h3 id="heading-2-trainpy">2. <code>train.py</code></h3>
<p>This is the file the agent lives in. It breaks naturally into four parts: the model, the optimizer (Muon for the matrix weights, AdamW for the embeddings and scalar parameters), the hyperparameters, and the training loop. We'll walk through each one with the goal of understanding why each piece exists.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a0065c6e3eebc2e20691ad8/a4847be2-2007-42e0-91bd-9599125b5ffc.png" alt="you can see in the image that the agent only controls the two green boxes in the middle, the model and the loop" style="display:block;margin:0 auto" width="1600" height="644" loading="lazy">

<p>The model is a fairly modern GPT written from scratch with no library dependencies beyond PyTorch and a Flash Attention 3 kernel. If you've read other GPT implementations the high-level structure will look familiar: a token embedding, a stack of transformer blocks, a normalization layer, and a linear head that projects back to vocabulary logits.</p>
<p>The interesting parts are in the details. I don’t think explaining the architecture or code is required for this repo, so I’ll just draw out a small architecture diagram for those of you who want to visualize it. Then I'll explain how the training loop is written.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a0065c6e3eebc2e20691ad8/42663aea-dace-4d97-8bf4-7294d61f8a0d.png" alt="simple explanation of the  model in train.py- token embedding feeding a stack of transformer blocks, then a normalization layer, then a linear head producing vocabulary logits" style="display:block;margin:0 auto" width="1600" height="1600" loading="lazy">

<p>The loop itself is short and almost pleasant to read. The skeleton is:</p>
<pre><code class="language-python">while True:
    # accumulate gradient over micro-batches to hit TOTAL_BATCH_SIZE
    for micro_step in range(grad_accum_steps):
        with autocast_ctx:
            loss = model(x, y)
        loss = loss / grad_accum_steps
        loss.backward()
        x, y, epoch = next(train_loader)

    # update LR / momentum / weight decay based on time elapsed
    progress = min(total_training_time / TIME_BUDGET, 1.0)
    # ... set group["lr"], group["momentum"], group["weight_decay"] ...

    optimizer.step()
    model.zero_grad(set_to_none=True)

    # log step metrics
    # ...

    if step &gt; 10 and total_training_time &gt;= TIME_BUDGET:
        break
</code></pre>
<p>There are a few things worth noticing here. First, the time budget is checked after the first 10 steps. This is so the budget doesn't include the initial PyTorch compilation (which can take 30 seconds or more). Without this, fast experiments would get penalized for spending half their budget on warmup.</p>
<p>Second, the loop has a fast-fail check. If the loss explodes or hits NaN it prints “FAIL” and exits. The agent then sees a crash and logs it. This is a defense against the agent doing something that diverges spectacularly.</p>
<p>Third, after the loop ends, there's a single final call to <code>evaluate_bpb</code> and then a structured summary printed to stdout.</p>
<p>That summary is the whole API between the training script and the agent:</p>
<pre><code class="language-yaml">---
val_bpb:          0.997900
training_seconds: 300.1
total_seconds:    325.9
peak_vram_mb:     45060.2
mfu_percent:      39.80
total_tokens_M:   499.6
num_steps:        953
num_params_M:     50.3
depth:            8
</code></pre>
<p>This is what the grep extracts and the agent reads. The whole experimental contract is seven lines of this plain text.</p>
<h4 id="heading-the-hyperparameters">The Hyperparameters</h4>
<p>The hyperparameters live in their own clearly-marked section near the bottom of <code>train.py</code>, with a comment that says "edit these directly, no CLI flags needed." They look like this:</p>
<pre><code class="language-yaml"># Model architecture
ASPECT_RATIO = 64       # model_dim = depth * ASPECT_RATIO
HEAD_DIM = 128          # target head dimension for attention
WINDOW_PATTERN = "SSSL" # sliding window pattern: L=full, S=half context

# Optimization
TOTAL_BATCH_SIZE = 2**19 # ~524K tokens per optimizer step
EMBEDDING_LR = 0.6
UNEMBEDDING_LR = 0.004
MATRIX_LR = 0.04
SCALAR_LR = 0.5
WEIGHT_DECAY = 0.2
ADAM_BETAS = (0.8, 0.95)
WARMUP_RATIO = 0.0
WARMDOWN_RATIO = 0.5
FINAL_LR_FRAC = 0.0

# Model size
DEPTH = 8
DEVICE_BATCH_SIZE = 128
</code></pre>
<p>Everything here is a deliberate single point of truth. The model dimension is computed from depth (<code>depth × 64</code>, rounded to the head dimension). The number of heads is computed from model dimension. This means that the agent can change one number <code>DEPTH</code>, and the model rescales itself coherently.</p>
<p>That kind of "one knob to scale the model" parameterization is exactly what makes a search space tractable.</p>
<h3 id="heading-3-programmd">3. <code>program.md</code></h3>
<p><code>program.md</code> is the shortest of the three files and is arguably the most important. It's the file that we edit and it contains everything the agent needs to know about how to behave during a run.</p>
<p>The structure of <code>program.md</code> mirrors the lifecycle of a research session. It opens with <strong>setup,</strong> agrees on a run tag, creates a Git branch named <code>autoresearch/&lt;tag&gt;</code>, reads the in-scope files, verifies that the data exists, and initializes a results file. It then describes the experimentation rules, like what the agent can and can't modify, that VRAM is a soft constraint, and crucially a simplicity criterion that says all else being equal, simpler is better.</p>
<p>A 0.001 bpb improvement that adds 20 lines of hacky code isn't worth keeping. A 0.001 bpb improvement that <strong>removes</strong> 20 lines is definitely worth keeping.</p>
<p>Then comes the actual loop. The agent is told to run training with <code>uv run train.py &gt; run.log 2&gt;&amp;1</code> and never to use <code>tee</code> or stream the output because that would flood the agent's context window. It's also told to extract metrics with <code>grep "^val_bpb:\|^peak_vram_mb:" run.log</code>, which gives just the one or two lines that matter.</p>
<p>If the grep produces nothing, that means the run crashed and the agent is told to read the last 50 lines of the log and try to fix the issue (but it should give up after a few attempts and move on). The result of every experiment is logged to <code>results.tsv</code>.</p>
<p>The decision rule is simple: if val_bpb improved (got lower) then the agent advances the branch by keeping its commit. If it didn't improve, the agent runs <code>git reset</code> to undo the commit. If it crashed, the agent logs that and tries something else.</p>
<p>The last paragraph of <code>program.md</code> is the one that makes autoresearch what it is. It's titled <strong>NEVER STOP</strong>. The agent is explicitly told not to ask the human (you) if it should keep going, not to ask for any permissions, and not to pause for confirmation. If the agent runs out of ideas, it should think harder, look at the failures, combine near-misses, and try more radical changes.</p>
<p>The loop runs until we interrupt it. This single instruction is more interesting than any line of Python in the repo. It's the difference between an agent that does a few experiments and asks if you want to continue and an agent that genuinely does autonomous research overnight.</p>
<p>There is no contradiction with the 5 minute budget. 5 minutes governs a single experiment, one training run. The "Never stop" instruction governs the outer loop. The moment one run finishes and the agent logs the result, it launches the next one. It keeps starting fresh 5 minute experiments back-to-back until you interrupt it.</p>
<p>Nothing ever trains for more than five minutes. The agent simply never stops starting new 5 minute trainings.</p>
<p>Now that you understand how it works, let’s start using it.</p>
<h2 id="heading-setup-guide">Setup Guide</h2>
<p>I'm assuming you have a single NVIDIA GPU with enough VRAM to run these experiments. Anything with 24GB or more should work with the default settings. Smaller GPUs need some tuning, which I'll cover later on.</p>
<h3 id="heading-step-1-install-uv-the-python-project-manager-the-repo-uses">Step 1: Install uv, the Python Project Manager the Repo Uses</h3>
<p>uv is much faster than pip and handles virtual environments transparently. After you install it, then clone the repo and install dependencies:</p>
<pre><code class="language-shell">curl -LsSf https://astral.sh/uv/install.sh | sh

git clone https://github.com/karpathy/autoresearch.git
cd autoresearch
uv sync
</code></pre>
<p>This will create a&nbsp;<code>.venv</code> and install pyTorch, Flash Attention, rustbpe, tiktoken, pyarrow, and a few other packages. It pulls PyTorch from the CUDA 12.8 wheel index, so make sure your driver supports that.</p>
<h3 id="heading-step-2-run-the-data-preparation">Step 2: Run the Data Preparation</h3>
<p>This downloads 10 ClimbMix shards plus the validation shard and then trains our tokenizer.</p>
<pre><code class="language-shell">uv run prepare.py
</code></pre>
<p>It takes about 2 minutes on a decent connection. If you have limited disk space, you can pass <code>--num-shards 4</code> for a smaller download. The data and tokenizer get cached in <code>~/.cache/autoresearch/</code>.</p>
<h3 id="heading-step-3-run-a-manual-training-experiement">Step 3: Run a Manual Training Experiement</h3>
<p>Now, you'll run a single training experiment manually, just to confirm that everything works end-to-end.</p>
<pre><code class="language-shell">uv run train.py
</code></pre>
<p>You should see the model compile (this takes 30 seconds or so the first time), then training output that looks something like this: <code>step 00050 (8.3%) | loss: 5.123456 | lrm: 1.00 | dt: 240ms | tok/sec: 2,184,533 | mfu: 39.8% | epoch: 1 | remaining: 275s</code>.</p>
<p>After about 5 minutes of training, plus an evaluation pass at the end, you'll get the summary block with <code>val_bpb</code> printed. That's your baseline.</p>
<h3 id="heading-step-4-hand-the-repo-to-an-agent">Step 4: Hand the Repo to an Agent</h3>
<p>In practice, this means opening Claude Code or your tool of choice in the repo directory, ideally with permissions disabled or scoped tightly to the repo, and prompting it with something like this:</p>
<pre><code class="language-plaintext">Have a look at program.md and let's kick off a new experiment.
Let's do the setup first.
</code></pre>
<p>The agent will read <code>program.md</code>, walk through the setup steps (creating the autoresearch branch and initializing <code>results.tsv</code>), confirm with you, and then start running. From this point on, you can leave it alone. When you come back, check <code>results.tsv</code> and the Git log on the autoresearch branch.</p>
<h3 id="heading-tuning-autoresearch-for-smaller-gpus">Tuning autoresearch for Smaller&nbsp;GPUs</h3>
<p>The default configuration assumes an H100. If you have a 4090, 3090, or anything with less than 80GB of VRAM, you'll need to dial things down.</p>
<ol>
<li><p>Lower the sequence length first: <code>MAX_SEQ_LEN = 2048</code> in <code>prepare.py</code> is the biggest VRAM lever since attention scales quadratically with it. Try 512 or even 256 on a small GPU and bump <code>DEVICE_BATCH_SIZE</code> in <code>train.py</code> slightly to compensate. The product of these two is the tokens-per-forward-pass.</p>
</li>
<li><p>Lower the depth: <code>DEPTH = 8</code> in <code>train.py</code> is the master knob for model size. Drop it to 4 on a small GPU and the model dimension automatically scales down with it.</p>
</li>
<li><p>Switch the window pattern: <code>WINDOW_PATTERN = "SSSL"</code> uses banded attention which is fast on H100 but can be slow on consumer GPUs, depending on the kernel implementation. Just <code>"L"</code> (always full attention) is simpler and often faster on smaller cards.</p>
</li>
<li><p>Lower the total batch size: <code>TOTAL_BATCH_SIZE = 2**19</code> is roughly 524K tokens per optimizer step. On a small GPU, drop it to 2^14 (~16K) to start.</p>
</li>
<li><p>Consider switching the dataset: climbMix is a hard broad web corpus. On a tiny model, the loss curve is noisy and bpb numbers are hard to interpret. Karpathy specifically recommends his own TinyStories-GPT4-Clean dataset for small-scale experimentation. The text is narrower in scope (children’s stories) so a small model can actually learn to generate something coherent in 5 minutes.</p>
</li>
</ol>
<p>There are already several community forks that have done the consumer-GPU tuning for you which you can check out in the repo's readme.md file.</p>
<h2 id="heading-what-the-agent-actually-finds">What the Agent Actually&nbsp;Finds</h2>
<p>It's one thing to describe how the loop works, and another to see what it produces. Karpathy was open about this on Twitter in his depth-12 run: the agent found about 20 changes that improved validation loss, all of which transferred to depth-24.</p>
<p>Specific examples from his post-run analysis include adding a learnable scalar to the parameterless QK-norm to sharpen attention, applying regularization to the value embeddings, widening the banded attention window, correcting the AdamW betas for certain parameter groups, tuning weight decay schedules, and adjusting initialization.</p>
<p>None of these would headline a research paper, but all of them showed up as 0.001 to 0.005 bpb improvements that stacked.</p>
<p>So it's not that an AI agent invented a new architecture. It's that the slow patient hill-climbing that real researchers spend months doing can be done by an agent in a couple of days. The result is the same boring detail-tuning that has always been where most of the actual progress in ML comes from.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>autoresearch doesn't introduce a new model or a new optimizer or a new dataset. It just defines a kind of contract between a human researcher and an AI agent and it shows that the contract can be enough. That contract is something like <em>“here is the fixed part of reality, the metric that judges you, a budget, and within those rules, do whatever you want and tell me what worked.”</em></p>
<p>There are two questions I still ponder that are worth thinking about. One is <strong>overfitting to the validation set</strong>. If you run hundreds of experiments against the same fixed validation shard, eventually the agent will start finding tweaks that look like wins on this shard but don't transfer. Karpathy himself called the results “fragile” in some sessions.</p>
<p>There's no obvious fix here yet beyond rotating validation data which would break comparability.</p>
<p>The other question is <strong>what the human’s role becomes</strong>. If the agent does the experiments, the human’s contribution shifts to shaping the search space and the rules. That is what <code>program.md</code> is. It's a pretty good preview of what research looks like when the loop is automated.</p>
<p>Well, that’s it for today. See you folks in my next article!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Automate PDF Data Extraction Using Python ]]>
                </title>
                <description>
                    <![CDATA[ PDFs are still one of the most widely used document formats in business. Financial reports, invoices, contracts, compliance filings, and operational documents are often shared as PDFs because they pre ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-automate-pdf-data-extraction-using-python/</link>
                <guid isPermaLink="false">6a20556a08e3e46121ab6d4e</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Wed, 03 Jun 2026 16:25:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/85626e6c-7433-4914-b094-19d784845d82.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>PDFs are still one of the most widely used document formats in business.</p>
<p>Financial reports, invoices, contracts, compliance filings, and operational documents are often shared as PDFs because they preserve formatting across devices and operating systems.</p>
<p>The problem is that PDFs are designed for presentation, not structured data analysis. Extracting information manually from these files is slow, repetitive, and highly prone to human error.</p>
<p>This becomes a major issue for teams that work with large volumes of documents every day.</p>
<p>Finance departments process invoices and statements, analysts review reports, and operations teams manage records that contain valuable structured data trapped inside static files.</p>
<p>Copying rows manually into spreadsheets doesn't scale, especially when organisations handle hundreds or thousands of PDFs each month.</p>
<p><a href="https://www.freecodecamp.org/learn/python-v9/">Python</a> has become one of the most effective tools for automating PDF data extraction because of its mature ecosystem of libraries and data processing frameworks.</p>
<p>Developers can build workflows that extract text, identify tables, clean inconsistent formatting, and export structured datasets into Excel or CSV files automatically.</p>
<p>In smaller workflows, some teams may simply choose to convert <a href="https://smallpdf.com/pdf-to-excel">PDF to Excel with SmallPDF</a> for quick spreadsheet conversions, while larger organizations often build fully automated extraction pipelines using Python for deeper customisation and control.</p>
<p>In this article, we'll explore how to automate PDF data extraction using Python, including how to extract text and tables from PDFs, clean and transform structured data, work with scanned documents using OCR, and export information into spreadsheet formats like Excel.</p>
<p>We'll also look at some of the most useful Python libraries for document automation and discuss the common challenges developers face when building scalable PDF processing workflows.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-understanding-pdf-structures">Understanding PDF Structures</a></p>
</li>
<li><p><a href="#heading-setting-up-the-python-environment">Setting Up the Python Environment</a></p>
</li>
<li><p><a href="#heading-extracting-text-from-pdfs">Extracting Text From&nbsp;PDFs</a></p>
</li>
<li><p><a href="#heading-extracting-tables-from-pdfs">Extracting Tables From&nbsp;PDFs</a></p>
</li>
<li><p><a href="#heading-working-with-ocr-for-scanned-pdfs">Working With OCR for Scanned&nbsp;PDFs</a></p>
</li>
<li><p><a href="#heading-building-end-to-end-automation-pipelines">Building End-to-End Automation Pipelines</a></p>
</li>
<li><p><a href="#heading-common-challenges-in-pdf-automation">Common Challenges in PDF Automation</a></p>
</li>
<li><p><a href="#heading-choosing-the-right-python-libraries">Choosing the Right Python Libraries</a></p>
</li>
<li><p><a href="#heading-the-future-of-pdf-automation">The Future of PDF Automation</a></p>
</li>
</ul>
<h2 id="heading-understanding-pdf-structures">Understanding PDF Structures</h2>
<p>One of the biggest misconceptions about PDFs is that they all behave the same way. In reality, PDFs can vary significantly depending on how they were generated.</p>
<p>Machine-readable PDFs contain embedded text that can be extracted directly using parsing libraries. These files are usually exported from software systems such as accounting tools, reporting platforms, or office applications. Since the text already exists digitally, extraction is relatively reliable.</p>
<p>Scanned PDFs are different. These documents are essentially images stored inside a PDF container. Since there's no actual text layer, extraction tools can't read the content directly. OCR software must first analyze the images and attempt to reconstruct readable text.</p>
<p>Before writing any code, you should always test whether the text inside a PDF can be selected manually. If text highlighting works normally, the file likely contains a machine-readable layer. If not, you'll probably need OCR.</p>
<h2 id="heading-setting-up-the-python-environment">Setting Up the Python Environment</h2>
<p>Python provides several excellent libraries for PDF extraction and document automation. Each library specializes in different aspects of the workflow.</p>
<p>Some tools focus on text extraction, while others are optimized for identifying tables or processing scanned documents. Commonly used libraries include pdfplumber, PyMuPDF, Camelot, tabula-py, and pytesseract.</p>
<p>You can configure the environment using pip:</p>
<p><code>pip install pdfplumber pandas openpyxl pymupdf camelot-py</code></p>
<p>If OCR support is required, you can also install some additional packages:</p>
<p><code>pip install pytesseract pillow</code></p>
<p><a href="https://www.projectpro.io/article/how-to-train-tesseract-ocr-python/561">Tesseract</a> itself must also be installed separately on the operating system because pytesseract acts only as a Python wrapper around the OCR engine.</p>
<p>Once the environment is ready, you can begin building extraction workflows tailored to specific document types.</p>
<h2 id="heading-extracting-text-from-pdfs">Extracting Text From&nbsp;PDFs</h2>
<p>The simplest PDF automation workflow involves extracting plain text from machine-readable documents.</p>
<p>Libraries such as <a href="https://ukconstructionblog.co.uk/plumbing-invoice-template/">pdfplumber</a> make this process straightforward:</p>
<pre><code class="language-plaintext">import pdfplumber

with pdfplumber.open(“report.pdf”) as pdf:

for page in pdf.pages:

text = page.extract_text()

print(text)
</code></pre>
<p>This approach works well for reports, contracts, meeting notes, and other text-heavy documents.</p>
<p>But raw text extraction often introduces formatting issues. Multi-column layouts may become scrambled, line breaks can appear unexpectedly, and tabular information may lose alignment completely.</p>
<p>While text extraction is useful for search indexing and keyword analysis, structured business workflows usually require table extraction instead.</p>
<h2 id="heading-extracting-tables-from-pdfs">Extracting Tables From&nbsp;PDFs</h2>
<p>Most business automation projects focus on extracting tables from PDFs into structured spreadsheet formats.</p>
<p><a href="https://github.com/atlanhq/camelot">Camelot</a> is one of the most widely used Python libraries for this purpose. It identifies table structures by analyzing page layouts and separating rows and columns automatically.</p>
<p>Here's a simple example:</p>
<pre><code class="language-plaintext">import camelot

tables = camelot.read_pdf(“financial_report.pdf”, pages=’1')

print(tables[0].df)
</code></pre>
<p>The extracted table is returned as a Pandas DataFrame, which makes downstream processing significantly easier.</p>
<p>Exporting the extracted data into Excel is straightforward:</p>
<pre><code class="language-plaintext">import pandas as pd

df = tables[0].df

df.to_excel(“output.xlsx”, index=False)
</code></pre>
<p>This type of workflow is extremely valuable for finance and operations teams that regularly process statements, invoices, audit reports, or procurement records.</p>
<p>Real-world PDFs, however, are rarely perfectly-structured. Tables may span multiple pages, contain merged cells, or use inconsistent spacing. You'll often need additional transformation logic to clean and standardize the extracted data before it becomes useful for analytics or reporting.</p>
<h2 id="heading-working-with-ocr-for-scanned-pdfs">Working With OCR for Scanned&nbsp;PDFs</h2>
<p>Scanned documents require OCR because there's no machine-readable text available inside the file.</p>
<p>Python devs commonly use Tesseract together with pytesseract for OCR workflows.</p>
<p>A simple example looks like this:</p>
<pre><code class="language-plaintext">from PIL import Image

import pytesseract

image = Image.open(“invoice_scan.png”)

text = pytesseract.image_to_string(image)

print(text)
</code></pre>
<p>OCR accuracy depends heavily on image quality. Low-resolution scans, skewed pages, handwritten content, and poor lighting can reduce recognition performance substantially.</p>
<p>To improve results, you can preprocess images before running OCR. Common preprocessing techniques include grayscale conversion, thresholding, sharpening, and noise reduction.</p>
<p>Even with preprocessing, OCR should generally be treated as a fallback solution rather than the primary extraction strategy whenever machine-readable PDFs are available.</p>
<h2 id="heading-building-end-to-end-automation-pipelines">Building End-to-End Automation Pipelines</h2>
<p>Single extraction scripts are useful for experimentation, but enterprise workflows usually require complete automation pipelines.</p>
<p>A production-ready document automation system may include file ingestion, document classification, extraction, transformation, validation, export, and archival stages.</p>
<p>Python works particularly well in these environments because it integrates cleanly with APIs, databases, cloud storage platforms, and workflow orchestration systems.</p>
<p>For example, an accounts payable workflow might automatically monitor an inbox for incoming invoices, extract tabular data from attached PDFs, validate totals, and push the cleaned records into an ERP platform without human intervention.</p>
<p>This type of automation can save organizations hundreds of hours of repetitive administrative work each month while improving consistency and reducing operational errors.</p>
<p>Many advanced systems also combine traditional extraction logic with AI models that automatically classify document types before routing them into specialized extraction pipelines.</p>
<h2 id="heading-common-challenges-in-pdf-automation">Common Challenges in PDF Automation</h2>
<p>PDF extraction becomes more difficult as workflows scale.</p>
<p>One major challenge is inconsistency. Documents generated from the same source system may still vary slightly in formatting, page layout, or spacing. Small formatting differences can break rigid extraction logic unexpectedly.</p>
<p>Accuracy validation is another critical issue. Extracted data should never be assumed correct automatically, especially in finance, healthcare, or compliance workflows where errors can create operational or regulatory risks.</p>
<p>Performance can also become a bottleneck when processing large volumes of files. Sequential extraction may be sufficient for small workloads, but larger systems often require parallel processing and queue-based architectures.</p>
<p>Scanned PDFs introduce even more uncertainty because OCR engines are inherently probabilistic. Many organizations use human review systems for low-confidence extractions instead of relying entirely on automation.</p>
<p>The most reliable automation systems combine structured extraction logic, validation rules, and selective manual oversight.</p>
<h2 id="heading-choosing-the-right-python-libraries">Choosing the Right Python Libraries</h2>
<p>Different libraries perform better depending on the structure and complexity of the documents being processed.</p>
<p>pdfplumber is excellent for lightweight text extraction and layout analysis. Camelot performs particularly well with clearly defined tables. <a href="https://pymupdf.readthedocs.io/en/latest/">PyMuPDF</a> offers strong performance and lower-level PDF manipulation capabilities.</p>
<p>For OCR workflows, <a href="https://pypi.org/project/pytesseract/">pytesseract</a> remains one of the most popular open-source solutions because it integrates easily into Python pipelines.</p>
<p>There's rarely a single perfect tool for every document type. Experienced developers typically combine multiple libraries within the same workflow and dynamically choose extraction strategies based on document characteristics.</p>
<p>Testing against real production data is critical because sample documents rarely capture the inconsistencies found in live operational environments.</p>
<h2 id="heading-the-future-of-pdf-automation">The Future of PDF Automation</h2>
<p>Document automation is evolving rapidly as AI systems become better at understanding unstructured information.</p>
<p>Traditional rule-based extraction workflows still dominate most enterprise systems, but AI-assisted models are increasingly capable of interpreting layouts, identifying fields, and understanding relationships between document elements more accurately than older parsing techniques.</p>
<p>Python remains central to this ecosystem because of its flexibility and extensive machine learning tooling. You can combine PDF extraction libraries with AI frameworks to build systems that continuously improve as they process more documents.</p>
<p>As organizations continue digitizing operations, automated PDF extraction will become increasingly important across finance, legal, healthcare, logistics, and compliance industries.</p>
<p>Teams that invest in document automation early can reduce manual work, improve reporting accuracy, and unlock structured business data that would otherwise remain trapped inside static PDF files.</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 Use Bash & Python for Real DevOps Automation – Full Handbook with 5 Production Use Cases ]]>
                </title>
                <description>
                    <![CDATA[ Automation scripts often validate process completion instead of system health. A Kubernetes pod can be running while the application inside it can't authenticate to the database. A Terraform deploymen ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-bash-python-for-real-devops-automation-handbook-with-production-use-cases/</link>
                <guid isPermaLink="false">6a171310badcd8afcb060460</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Bash ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Wed, 27 May 2026 15:51:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/73f2a745-c1b5-4cbb-8f97-2ba6c5230592.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Automation scripts often validate process completion instead of system health.</p>
<p>A Kubernetes pod can be running while the application inside it can't authenticate to the database. A Terraform deployment can return clean while someone has manually changed infrastructure in the cloud console. A canary rollout can show zero errors while users wait five seconds for every request.</p>
<p>The problem isn't the tooling. The problem is that the system can look healthy when it really is not.</p>
<p>This handbook walks through five production-style automation scenarios using Bash and Python for:</p>
<ul>
<li><p>Detecting abnormal AWS spend before the monthly invoice arrives</p>
</li>
<li><p>Correlating logs across multiple services using trace IDs</p>
</li>
<li><p>Finding infrastructure drift outside Terraform</p>
</li>
<li><p>Validating secret rotation at the application level</p>
</li>
<li><p>Automatically rolling back slow deployments before users complain</p>
</li>
</ul>
<p>By the end of this handbook, you'll be able to build small scripts that help you notice when something is wrong in a system, even when the tools say everything is fine.</p>
<p>The scripts are intentionally small. The important part is the operational thinking behind them like what signal the script measures, what failure mode it can detect, and what assumptions the platform is making underneath.</p>
<p>Each use case includes a runnable demo environment, the complete script, a breakdown of the system behaviour involved, and an intentional failure you can trigger yourself.</p>
<p>If you're new to this workflow, start with use case 1 and work forward. The later sections build on the same pattern: automation is useful when it verifies reality, not just process completion.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, set up the following:</p>
<ul>
<li><p><strong>Python 3.8 or higher</strong> – check with <code>python3 --version</code></p>
</li>
<li><p><strong>A Python virtual environment</strong> – create one before installing anything:</p>
</li>
</ul>
<pre><code class="language-plaintext">python3 -m venv venv
source venv/bin/activate  

 # on Windows: 

venv\Scripts\activate
</code></pre>
<p>This keeps your installed packages isolated from your system Python and prevents permission errors on shared machines.</p>
<ul>
<li><p><strong>pip</strong> – Python's package installer, included with Python</p>
</li>
<li><p><strong>AWS CLI</strong> configured with a working profile – a free-tier AWS account is enough for use cases 1, 3, and 4. Verify it's working with:</p>
<pre><code class="language-plaintext">aws sts get-caller-identity
</code></pre>
</li>
<li><p><strong>Docker and Docker Compose</strong> – needed for use cases 2, 4, and 5</p>
</li>
<li><p><strong>Kind</strong> (Kubernetes in Docker) – a way to run Kubernetes locally for use cases 4 and 5. Install with <code>brew install kind</code> on macOS, or follow the <a href="https://kind.sigs.k8s.io/docs/user/quick-start/">Kind quick start guide</a></p>
</li>
<li><p><strong>kubectl</strong> – the command-line tool for talking to a Kubernetes cluster. After installing Kind, run <code>kind create cluster</code> and kubectl is configured automatically</p>
</li>
<li><p><strong>Helm</strong> – a package manager for Kubernetes, needed for use case 5. Install with <code>brew install helm</code> or the <a href="https://helm.sh/docs/intro/install/">Helm install guide</a></p>
</li>
<li><p><strong>Terraform</strong> – needed for use case 3. Install with <code>brew install terraform</code> on macOS or follow the <a href="https://developer.hashicorp.com/terraform/install">Terraform install guide</a>. Check with <code>terraform version</code>.</p>
</li>
<li><p><strong>bc</strong> – a calculator utility used by the canary watch scripts for floating-point comparison. Install with <code>brew install bc</code> on macOS or <code>apt install bc</code> on Ubuntu. Run <code>bc --version</code> to confirm it is available before starting use case 5.</p>
</li>
</ul>
<h3 id="heading-knowledge-and-skills">Knowledge and Skills</h3>
<ul>
<li><p>You should be comfortable reading Python and Bash scripts without needing to write them from scratch.</p>
</li>
<li><p>You should have basic Linux terminal comfort – navigating directories, running scripts, reading output, and so on.</p>
</li>
<li><p>You should know what Kubernetes pods and deployments are at a basic level – you don't need deep Kubernetes expertise, as use cases 4 and 5 will introduce the Kubernetes concepts they rely on as they go.</p>
</li>
<li><p>Familiarity with AWS basics such as what EC2, IAM, and Secrets Manager will help with use cases 1, 3, and 4, while use case 2 runs entirely on your local machine and requires no AWS knowledge at all.</p>
</li>
<li><p>For use case 3, knowing what Terraform is and what a state file does will help. You don't need to write any Terraform, but understanding that Terraform tracks and what it created is the foundation of the whole use case.</p>
</li>
</ul>
<h3 id="heading-aws-iam-permissions-required">AWS IAM Permissions Required</h3>
<p>The scripts in this article make real AWS API calls. Your IAM user or role needs the following minimum permissions. (If you see an <code>AccessDenied</code> error, this is the first place to look.):</p>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>Required IAM Permission</th>
</tr>
</thead>
<tbody><tr>
<td>1 - Cost Anomaly Detection</td>
<td><code>ce:GetCostAndUsage</code></td>
</tr>
<tr>
<td>3 - Drift Detection</td>
<td><code>ec2:DescribeSecurityGroups</code></td>
</tr>
<tr>
<td>4 - Secrets Rotation</td>
<td><code>secretsmanager:GetSecretValue</code>, <code>secretsmanager:PutSecretValue</code></td>
</tr>
</tbody></table>
<p>If you're using a fresh AWS free-tier account with <code>AdministratorAccess</code> attached, these permissions are already included and you can skip this step.</p>
<p>If you're on a restricted IAM user, here's how to add them. In the AWS Console, go to IAM, click Users, then click your username. Under the Permissions tab, click Add permissions, then Create inline policy.</p>
<p>Switch to the JSON tab and paste a policy document granting the permissions in the table above, then save it.</p>
<p>If your company manages AWS through an organization and you don't have permission to edit your own IAM policies, ask your administrator to add these permissions to your role.</p>
<h3 id="heading-companion-github-repository">Companion GitHub Repository</h3>
<p>All demo projects live at: <a href="https://github.com/Osomudeya/devops-scripting-labs"><strong>https://github.com/irvingtalks/devops-scripting-labs</strong></a></p>
<p>Each use case has its own numbered folder with the complete script, supporting files, a <code>setup.sh</code> to prepare the environment, and a <code>break_it.sh</code> that injects the specific failure each use case is built around.</p>
<p>Clone the repo before starting:</p>
<pre><code class="language-plaintext">git clone https://github.com/irvingtalks/devops-scripting-labs
cd devops-scripting-labs
</code></pre>
<p>Before running any use case, check that you have everything installed:</p>
<pre><code class="language-plaintext">./preflight.sh
</code></pre>
<p>This checks for every tool the lab needs like Python, AWS CLI, Docker, Kind, Helm, Terraform, and <code>bc</code> and tells you exactly what's missing with the install command for each one.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-use-case-1-cost-anomaly-detection">Use Case 1 - Cost Anomaly Detection</a></p>
</li>
<li><p><a href="#heading-use-case-2-log-correlation-across-services">Use Case 2 - Log Correlation Across Services</a></p>
</li>
<li><p><a href="#heading-use-case-3-infrastructure-drift-detection">Use Case 3 - Infrastructure Drift Detection</a></p>
</li>
<li><p><a href="#heading-use-case-4-secrets-rotation-with-zero-downtime">Use Case 4 - Secrets Rotation with Zero Downtime</a></p>
</li>
<li><p><a href="#heading-use-case-5-automated-canary-rollback-trigger">Use Case 5 - Automated Canary Rollback Trigger</a></p>
</li>
<li><p><a href="#heading-what-you-can-do-now">What You Can Do Now</a></p>
</li>
</ul>
<h2 id="heading-use-case-1-cost-anomaly-detection">Use Case 1 - Cost Anomaly Detection</h2>
<p><strong>Environment:</strong> AWS Cost Explorer API (read-only, available in all accounts) <strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p>A junior engineer is testing a Kubernetes configuration. They spin up a managed node group in AWS (a set of EC2 virtual machines that the Kubernetes cluster uses to run workloads) and configure the cluster autoscaler, which is the Kubernetes component responsible for adding more machines when the cluster needs more capacity. The test goes well, and on Friday afternoon, they forget to tear the environment down.</p>
<p>Over the weekend, the autoscaler keeps provisioning new nodes because the test workloads are still running and requesting resources. By Monday morning you have a node group that has been quietly growing for two and a half days, and nobody noticed until the invoice landed three weeks later.</p>
<p>The script in this use case exists because your AWS bill isn't just a monthly number. It's a time series, and you can monitor it the same way you monitor application metrics. Check it daily, know your baseline, and you catch this kind of event in hours instead of weeks.</p>
<h3 id="heading-whats-actually-happening-at-the-system-level">What's Actually Happening at the System Level</h3>
<p><strong>What this is not:</strong> This isn't a finance dashboard. It's an operational anomaly detector and the signal it monitors is cost. But the thing it's actually detecting is unexpected infrastructure behavior such as resources left running, autoscaler events, and forgotten environments.</p>
<p>AWS Cost Explorer is a service that stores your billing data and exposes it through an API, and when you call it, you're running a query against your account's billing records by specifying the time range, the granularity, and how you want results grouped.</p>
<p>One thing to know before you start investigating any flagged cost is that AWS decides which service category to put a charge under, not you. An EBS snapshot copy running across regions might appear under the EC2 line item rather than data transfer, which means a spike in EC2 spend doesn't necessarily mean something went wrong with your EC2 instances. The script flags the spike correctly, but investigating it means asking <em>"what changed in my infrastructure on this date"</em> rather than <em>"what is running in EC2 right now."</em></p>
<p>The billing label is a starting point, not a diagnosis.</p>
<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>01-cost-anomaly/</code> in the <a href="https://github.com/Osomudeya/devops-scripting-labs">companion repo</a>. No cluster setup is needed for this use case because the script runs against your AWS account directly, and the only dependency is boto3:</p>
<pre><code class="language-plaintext">cd 01-cost-anomaly
pip install boto3
</code></pre>
<p>Before running against your real account, make sure your AWS credentials are configured. The script uses whatever credentials the AWS CLI is set up with. If you haven't done this yet:</p>
<pre><code class="language-plaintext">aws configure
</code></pre>
<p>This will ask for your AWS Access Key ID, Secret Access Key, default region (use <code>us-east-1</code> if unsure), and output format (type <code>json</code>). You can find your access keys in the AWS Console under IAM → Users → your username → Security credentials → Create access key.</p>
<p>Your account needs the <code>ce:GetCostAndUsage</code> permission also, if you're on a fresh account with AdministratorAccess that's already included.</p>
<p>If you have an AWS account with a few weeks of billing history, you can run the script directly against your real data:</p>
<pre><code class="language-plaintext">python detect_cost_anomaly.py
</code></pre>
<p>Two things to know before running against a real account. First, Cost Explorer data has a 24-hour lag. This means spend from today won't appear until tomorrow, so the script automatically excludes the most recent day to avoid incomplete results.</p>
<p>Second, the script uses unblended costs, which is what you actually pay on a single-account setup. Blended costs are a weighted average used in multi-account organisations sharing reserved capacity and will give different numbers.</p>
<p>If you have a new account or prefer not to use real billing data, the script includes a <code>--sample</code> flag that uses built-in data and calls no AWS APIs at all.<br>Run this first to see what the output looks like before reading the code:</p>
<pre><code class="language-plaintext">python detect_cost_anomaly.py --sample
</code></pre>
<h3 id="heading-the-script">The Script</h3>
<pre><code class="language-python">#!/usr/bin/env python3
# detect_cost_anomaly.py — Use Case 1: Cost Anomaly Detection
# Full explanation of every function is in the article.

import statistics
import sys
from datetime import datetime, timedelta

import boto3

def build_sample_data(days=30):
    """Synthetic Cost Explorer rows for the last `days` (ending yesterday).

    The EC2 spike is placed on yesterday (device local date) so sample output
    always matches the same window as live Cost Explorer mode.
    """
    last_day = datetime.today().date() - timedelta(days=1)
    first_day = last_day - timedelta(days=days - 1)
    anomaly_day_index = days - 1
    results = []
    for i in range(days):
        day = first_day + timedelta(days=i)
        d = i + 1
        results.append(
            {
                "TimePeriod": {
                    "Start": str(day),
                    "End": str(day + timedelta(days=1)),
                },
                "Groups": [
                    {
                        "Keys": ["Amazon EC2"],
                        "Metrics": {
                            "UnblendedCost": {
                                "Amount": str(
                                    round(
                                        18.50
                                        if i == anomaly_day_index
                                        else 1.10 + (d % 3) * 0.10,
                                        2,
                                    )
                                )
                            }
                        },
                    },
                    {
                        "Keys": ["Amazon S3"],
                        "Metrics": {
                            "UnblendedCost": {
                                "Amount": str(round(0.04 + (d % 5) * 0.01, 2))
                            }
                        },
                    },
                    {
                        "Keys": ["Amazon RDS"],
                        "Metrics": {
                            "UnblendedCost": {
                                "Amount": str(round(0.85 + (d % 4) * 0.05, 2))
                            }
                        },
                    },
                ],
            }
        )
    return results, str(last_day)


def get_daily_costs(days=30):
    ce = boto3.client("ce", region_name="us-east-1")
    end = datetime.today().date() - timedelta(days=1)
    start = end - timedelta(days=days)
    response = ce.get_cost_and_usage(
        TimePeriod={"Start": str(start), "End": str(end)},
        Granularity="DAILY",
        Metrics=["UnblendedCost"],
        GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
    )
    return response["ResultsByTime"]


def build_service_timeseries(results):
    services = {}
    for day in results:
        date_str = day["TimePeriod"]["Start"]
        for group in day["Groups"]:
            service = group["Keys"][0]
            cost = float(group["Metrics"]["UnblendedCost"]["Amount"])
            if service not in services:
                services[service] = []
            services[service].append({"date": date_str, "cost": cost})
    return services


def detect_anomalies(services, baseline_days=7, multiplier=2.0, recent_days=None):
    """Flag days where cost exceeds prior `baseline_days` average + 2σ.

    Uses a rolling baseline (each day vs the previous week). If `recent_days`
    is set, only returns anomalies on or after today - recent_days.
    """
    cutoff = None
    if recent_days is not None:
        cutoff = datetime.today().date() - timedelta(days=recent_days)

    anomalies = []
    for service, daily in services.items():
        if len(daily) &lt; baseline_days + 1:
            continue
        for i in range(baseline_days, len(daily)):
            day = daily[i]
            day_date = datetime.strptime(day["date"], "%Y-%m-%d").date()
            if cutoff is not None and day_date &lt; cutoff:
                continue
            baseline_costs = [d["cost"] for d in daily[i - baseline_days : i]]
            avg = statistics.mean(baseline_costs)
            if avg &lt; 0.01:
                continue
            try:
                std = statistics.stdev(baseline_costs)
            except statistics.StatisticsError:
                continue
            threshold = avg + (multiplier * std)
            if day["cost"] &gt; threshold:
                anomalies.append(
                    {
                        "service": service,
                        "date": day["date"],
                        "actual": round(day["cost"], 4),
                        "baseline_avg": round(avg, 4),
                        "threshold": round(threshold, 4),
                        "pct_above": round(((day["cost"] - avg) / avg) * 100, 1),
                    }
                )
    return sorted(anomalies, key=lambda x: x["date"])


def parse_args(argv):
    use_sample = "--sample" in argv
    recent_days = None
    for arg in argv[1:]:
        if arg.startswith("--recent-days="):
            recent_days = int(arg.split("=", 1)[1])
    return use_sample, recent_days


def run(use_sample=False, recent_days=None):
    if use_sample:
        results, anomaly_date = build_sample_data()
        print("Running against sample data (--sample mode).")
        print(
            f"This data represents 30 days of billing ending yesterday, "
            f"with a realistic EC2 anomaly on {anomaly_date}.\n"
        )
    else:
        print("Fetching 30 days of daily AWS costs by service...")
        print("Note: today is excluded — Cost Explorer has a 24-hour billing lag.\n")
        results = get_daily_costs(days=30)

    if recent_days is not None:
        since = datetime.today().date() - timedelta(days=recent_days)
        print(
            f"Checking for spikes in the last {recent_days} days only "
            f"(on or after {since}), each vs its prior 7-day average.\n"
        )

    services = build_service_timeseries(results)
    anomalies = detect_anomalies(services, recent_days=recent_days)

    if not anomalies:
        print("No anomalies detected.")
        print("\nNote: this script flags statistical outliers against your own baseline.")
        print("A consistently elevated spend level will not trigger — only sudden increases.")
        return

    print(f"{'=' * 60}")
    print(f"ANOMALIES DETECTED: {len(anomalies)}")
    print(f"{'=' * 60}\n")

    for a in anomalies:
        print(f"Service:      {a['service']}")
        print(f"Date:         {a['date']}")
        print(f"Actual cost:  ${a['actual']}")
        print(f"Baseline avg: ${a['baseline_avg']} (prior 7-day average)")
        print(f"Threshold:    ${a['threshold']}")
        print(f"Overage:      {a['pct_above']}% above baseline")
        print()

    print("=" * 60)
    print("A note on AWS cost attribution:")
    print("The service label in Cost Explorer is assigned by AWS, not by the resource")
    print("that caused the cost. An EC2 spike may be caused by EBS snapshot copies,")
    print("cross-region data transfer, or autoscaling events that AWS categorizes under")
    print("EC2 in billing — not a running EC2 instance you can find in the console.")
    print()
    print("Before investigating the flagged service directly, ask:")
    print("What changed in my infrastructure on or before the flagged date?")
    print("Work backward from the operational change, not forward from the billing label.")


if __name__ == "__main__":
    use_sample, recent_days = parse_args(sys.argv)
    run(use_sample=use_sample, recent_days=recent_days)
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>get_daily_costs</code> pulls your AWS billing data for the last 30 days.</p>
<p><code>build_service_timeseries</code> takes the raw data from AWS and reorganises it. AWS groups the data by day first, then by service. This function flips that around so each service has its own list of daily costs, which is what the detection step needs to work with.</p>
<p><code>detect_anomalies</code> is where the actual check happens. For each service, it compares each day's spend to the 7 days right before it. If yesterday cost dramatically more than the week before, the script flags it. That's all it does.</p>
<p><code>--recent-days=7</code> means <em>"only show me anomalies from the last 7 days."</em> The script still fetches 30 days of data because it needs that history to calculate the comparison, but the results are filtered to the window you care about. This is good for a quick Monday morning check.</p>
<p><code>--sample</code> runs without touching your AWS account at all. It uses built-in fake billing data with a spike baked into yesterday's date so the detection always fires. Use this first to see what the output looks like before connecting it to real data.</p>
<h3 id="heading-what-the-output-looks-like">What the Output Looks Like</h3>
<p>Running <code>--sample</code> (the spike date will show as yesterday's actual date, not a fixed value):</p>
<pre><code class="language-plaintext">Running against sample data (--sample mode).
30 days of billing ending yesterday, with an EC2 spike on 2026-05-14.

============================================================
ANOMALIES DETECTED: 1
============================================================

Service:      Amazon EC2
Date:         2026-05-14
Actual cost:  $18.5
Baseline avg: $1.2143 (prior 7-day average)
Threshold:    $1.3939
Overage:      1423.4% above baseline

============================================================
A note on AWS cost attribution:
The service label in Cost Explorer is assigned by AWS, not by the resource
that caused the cost. An EC2 spike may be caused by EBS snapshot copies,
cross-region data transfer, or autoscaling events that AWS categorizes under
EC2 in billing - not a running EC2 instance you can find in the console.

Before investigating the flagged service directly, ask:
What changed in my infrastructure on or before the flagged date?
Work backward from the operational change, not forward from the billing label.
</code></pre>
<p>Your numbers will differ slightly from the above because the sample data generates dates from today dynamically. The spike always shows up on yesterday and the surrounding baseline numbers shift depending on the day you run it.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make for You</h3>
<p>The anomaly is on the EC2 line, and the instinct is to go look at running EC2 instances. But as the output warns, the attribution is AWS's choice, not yours.</p>
<p>Before opening the EC2 console, check your deployment history for that date. What was deployed? Was a new environment created? Did an autoscaler event run? Start from the operational change and follow the thread to the billing data, because starting from the billing label and working backward is slower and frequently misleading.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<pre><code class="language-bash"># See the spike immediately with no AWS account needed
python detect_cost_anomaly.py --sample

# Run against your real account
python detect_cost_anomaly.py

# Only show anomalies from the last 7 days, good for a quick this-week check
python detect_cost_anomaly.py --recent-days=7

# Combine both flags - sample data filtered to the last 7 days
python detect_cost_anomaly.py --sample --recent-days=7
</code></pre>
<p><strong>If your real account returns "No anomalies detected" that's not a failure.</strong> It means your spend has been consistent. A clean account returns clean output. The script is doing exactly what it should.</p>
<p>When a real event happens on your account such as an autoscaler left running, a forgotten environment or an unexpected data transfer, this is what catches it before the invoice does.</p>
<h2 id="heading-use-case-2-log-correlation-across-services">Use Case 2 – Log Correlation Across Services</h2>
<p><strong>Environment:</strong> Fully local – Docker Compose, three Python services<br><strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p>A user reports that their payment failed. You open your logging tool and search. The auth service logged a successful authentication. The ledger service logged a successful transaction but the notification service which should have sent a payment confirmation email has logged nothing at all.</p>
<p>Two services reported success while one service stay silent. The payment still failed, and you have three logs and no clear answer about where the chain broke.</p>
<h3 id="heading-whats-actually-happening-at-the-system-level">What's Actually Happening at the System Level</h3>
<p><strong>What this is not:</strong> This isn't a guide to installing a log aggregation tool. It's about the data structure that makes log correlation possible in the first place and what happens when that structure breaks on one service's error path.</p>
<p>In a system with a single service, debugging is simple: one service, one log file, one timeline. But when a user request passes through multiple services, you need a way to link all the logs together. That link is called a trace ID.</p>
<p>Think of it like a ticket number at a government office. When you walk in, you get a number, say, A247. Every desk that handles your case writes A247 on your file. If something goes wrong, the manager pulls every record with A247 and sees exactly what happened, in order, across every desk. That is a trace ID. One number, shared across every service that touched the request.</p>
<p>In the demo, when a payment comes in, the auth service creates a unique ID for it. Every log line that auth, ledger, and notification write for that payment includes the same ID. When something breaks, you run <code>correlate.py</code> with that ID and it finds every related log line across all three services and sorts them by time:</p>
<pre><code class="language-plaintext">python correlate.py pay-abc123
</code></pre>
<p>Here's what those logs look like. Notice that every line has the same <code>trace_id</code>:</p>
<pre><code class="language-json">{"timestamp": "2026-05-01T14:23:01.234Z", "trace_id": "pay-abc123", "service": "auth", "event": "user_authenticated", "level": "INFO", "user_id": "u_789", "duration_ms": 12}
{"timestamp": "2026-05-01T14:23:01.891Z", "trace_id": "pay-abc123", "service": "ledger", "event": "transaction_recorded", "level": "INFO", "amount": 50.0, "currency": "USD"}
{"timestamp": "2026-05-01T14:23:02.103Z", "trace_id": "pay-abc123", "service": "notification", "event": "email_queued", "level": "INFO", "recipient": "user@example.com"}
</code></pre>
<p>Now here's what breaks it. The notification service hits a timeout connecting to the email provider. The developer who wrote the error handler forgot to include the trace ID, so instead of a proper log line, it writes this:</p>
<pre><code class="language-plaintext">2026-05-01T14:23:02.415Z ERROR Connection timeout to email provider smtp.example.com:587
</code></pre>
<p>The error happened, the log line exists. But because it has no <code>trace_id</code>, <code>correlate.py</code> can't find it.</p>
<p>The notification still appears in the timeline, and you can see <code>email_send_attempt</code> – but <code>email_queued</code> never follows it.</p>
<pre><code class="language-plaintext">Timeline — 5 events across 3 service(s):

  [2026-05-15T21:59:00.605307+00:00] [AUTH] [INFO] payment_request_received
  [2026-05-15T21:59:00.606008+00:00] [AUTH] [INFO] user_authenticated
  [2026-05-15T21:59:00.617331+00:00] [LEDGER] [INFO] transaction_recorded
  [2026-05-15T21:59:00.630313+00:00] [NOTIFICATION] [INFO] email_send_attempt
  [2026-05-15T21:59:00.685182+00:00] [AUTH] [INFO] payment_complete
</code></pre>
<p>The attempt is there but the failure is not. The developer just forgot one field.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/22b7d7b0-8ae5-4573-bcb0-faaf5d807e8a.png" alt="log correlation attempt terminal output - ERROR Connection timeout" style="display:block;margin:0 auto" width="1041" height="82" loading="lazy">

<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>02-log-correlation/</code> and start the three services:</p>
<pre><code class="language-plaintext">cd 02-log-correlation
docker compose up -d
</code></pre>
<p>This starts the auth, ledger, and notification services. Trigger a payment request to generate some logs:</p>
<pre><code class="language-plaintext">./trigger_request.sh
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/06977757-e7fd-43c3-aaca-dbc6d17c951a.png" alt="trigger_request.sh terminal output - also showing the traceid" style="display:block;margin:0 auto" width="630" height="131" loading="lazy">

<p>The script prints the trace ID it used. Copy the ID and Run the correlation script against it now, before we break anything, to see the full working path:</p>
<pre><code class="language-plaintext">python correlate.py pay-5831e1bf
</code></pre>
<p>You should see something like this (your trace ID will be different but the structure is the same):</p>
<pre><code class="language-plaintext">Loading logs from ./logs/...
Loaded 6 structured log lines.

============================================================
Trace ID: pay-5831e1bf
============================================================

Timeline - 6 events across 3 service(s):

  [2026-05-15T21:42:28.079046+00:00] [AUTH] [INFO] payment_request_received
    service: auth
    user_id: u_789
    amount: 50.0
  [2026-05-15T21:42:28.080718+00:00] [AUTH] [INFO] user_authenticated
    service: auth
    user_id: u_789
    duration_ms: 12
  [2026-05-15T21:42:28.145528+00:00] [LEDGER] [INFO] transaction_recorded
    service: ledger
    user_id: u_789
    amount: 50.0
    currency: USD
  [2026-05-15T21:42:28.210088+00:00] [NOTIFICATION] [INFO] email_send_attempt
    service: notification
    recipient: user@example.com
  [2026-05-15T21:42:28.347893+00:00] [NOTIFICATION] [INFO] email_queued
    service: notification
    recipient: user@example.com
    amount: 50.0
  [2026-05-15T21:42:28.378402+00:00] [AUTH] [INFO] payment_complete
    service: auth
    user_id: u_789
    amount: 50.0
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/26a0b226-4e3d-4967-a7c6-6d367409fb1d.png" alt="terminal output showing the full payment journey" style="display:block;margin:0 auto" width="1100" height="559" loading="lazy">

<p>That's the full payment journey with auth, ledger, notification in the exact order it happened. Now let's look at how the script works.</p>
<h3 id="heading-the-script">The Script</h3>
<pre><code class="language-python"># correlate.py
import json
import os
import sys

SERVICES = ["auth", "ledger", "notification"]
LOG_DIR = "./logs"


def load_logs(log_dir):
    """
    Read each service's log file and parse every line as JSON.
    Lines that fail JSON parsing are printed as warnings.
    They are not silently dropped - a plain-text error line in a service
    that should emit structured logs is itself evidence worth seeing.
    """
    all_lines = []

    for service in SERVICES:
        log_file = os.path.join(log_dir, f"{service}.log")

        if not os.path.exists(log_file):
            print(f"  WARNING: No log file for '{service}' at {log_file}")
            continue

        with open(log_file) as f:
            for line_num, line in enumerate(f, 1):
                line = line.strip()
                if not line:
                    continue
                try:
                    parsed = json.loads(line)
                    parsed["_source"] = service
                    all_lines.append(parsed)
                except json.JSONDecodeError:
                    # This line exists in the log but cannot be correlated.
                    print(f"  WARNING: {service}.log line {line_num} is not structured JSON:")
                    print(f"           {line[:100]}")
                    print(f"           This line will NOT appear in any trace-based search.")

    return all_lines


def correlate(trace_id, all_lines):
    """
    Find every log line with this trace_id and sort by timestamp.
    The sorted result is the reconstructed timeline of the request.
    """
    matched = [line for line in all_lines if line.get("trace_id") == trace_id]
    matched.sort(key=lambda x: x.get("timestamp", ""))
    return matched


def find_missing_services(matched):
    """
    Check which services produced zero trace-tagged lines for this request.
    A missing service is not just an absence - it is a signal.
    Either the request never reached that service, or an error path swallowed
    the trace ID. Both are worth investigating.
    """
    services_seen = {line["_source"] for line in matched}
    return [s for s in SERVICES if s not in services_seen]


def print_timeline(trace_id, matched, missing):
    print(f"\n{'=' * 60}")
    print(f"Trace ID: {trace_id}")
    print(f"{'=' * 60}")

    if not matched:
        print("\nNo structured log lines found with this trace ID.")
        print("Either the trace ID is wrong, or no service emitted")
        print("a structured log line for this request.")
        return

    services_count = len({line["_source"] for line in matched})
    print(f"\nTimeline - {len(matched)} events across {services_count} service(s):\n")

    for line in matched:
        ts = line.get("timestamp", "unknown")
        service = line.get("_source", "unknown").upper()
        event = line.get("event", "unknown event")
        level = line.get("level", "INFO")
        extras = {k: v for k, v in line.items()
                  if k not in ("timestamp", "trace_id", "event", "level", "_source")}

        print(f"  [{ts}] [{service}] [{level}] {event}")
        for k, v in extras.items():
            print(f"    {k}: {v}")

    if missing:
        print(f"\n{'=' * 60}")
        print("MISSING TELEMETRY")
        print(f"{'=' * 60}")
        print(f"These services produced no trace-tagged events for trace {trace_id}:\n")
        for s in missing:
            print(f"  - {s}")
        print()
        print("This means one of three things:")
        print("  1. The request never reached this service.")
        print("  2. The service received it but an error path swallowed the trace ID,")
        print("     leaving a plain-text log line that trace correlation cannot find.")
        print("  3. This service's log file was not included in this run.")
        print()
        print("Check the raw log file for a plain-text error line around the same timestamp.")
        print("If one exists, that is your root cause - and a structured logging gap to fix.")


def run(trace_id):
    print(f"Loading logs from {LOG_DIR}/...")
    all_lines = load_logs(LOG_DIR)
    print(f"Loaded {len(all_lines)} structured log lines.\n")

    matched = correlate(trace_id, all_lines)
    missing = find_missing_services(matched)
    print_timeline(trace_id, matched, missing)


if __name__ == "__main__":
    if len(sys.argv) &lt; 2:
        print("Usage: python correlate.py &lt;trace_id&gt;")
        print("Example: python correlate.py pay-abc123")
        sys.exit(1)
    run(sys.argv[1])
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>load_logs</code> reads log files from each service. Each line should be JSON. If a line isn't JSON, it prints a warning that usually means an error log is missing a trace ID and can't be tracked.</p>
<p><code>correlate</code> finds all logs that match the given trace ID and sorts them by time. This rebuilds the full request flow across services.</p>
<p><code>find_missing_services</code> checks which services have no logs for that trace ID. This tells you where the request stopped or where the trace ID was lost.</p>
<p><code>print_timeline</code> displays the full request timeline in order. It also shows which services are missing if something didn't log correctly.</p>
<p>One thing worth knowing for when you use this in a real Kubernetes environment:<br>in Kubernetes, <code>kubectl logs</code> only shows the current running container.<br>If a pod restarts, you can use this:</p>
<pre><code class="language-plaintext">kubectl logs &lt;pod-name&gt; --previous
</code></pre>
<p>But this only works for the last restart. Older logs are gone unless you use a logging system like Loki or CloudWatch.</p>
<h3 id="heading-what-the-output-looks-like-after-breaking-it">What the Output Looks Like After Breaking it</h3>
<p>The point of this section is to show you what happens when a service fails silently, – when the error exists in the logs but the script can't find it because the developer forgot one field.</p>
<p><code>break_it.sh</code> forces the notification service to fail when it tries to send an email, and because the error handler was written without a trace ID, the failure gets logged as plain text with no way to tie it back to the original request.</p>
<p>Run it:</p>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>Then trigger a new request:</p>
<pre><code class="language-plaintext">./trigger_request.sh
</code></pre>
<p>Copy the trace ID it prints, then correlate it:</p>
<pre><code class="language-plaintext">python correlate.py pay-xxxxxxxx
</code></pre>
<p>Here is what you'll see:</p>
<pre><code class="language-plaintext">Loading logs from ./logs/...
  WARNING: notification.log line 10 is not structured JSON:
           2026-05-15T21:59:00.681583+00:00 ERROR Connection timeout to email
           provider http://mock-email:80/ after 0.001s - failed to send
           confirmation to user@example.com
           This line will NOT appear in any trace-based search.
Loaded 29 structured log lines.

============================================================
Trace ID: pay-6cf69a8c
============================================================

Timeline - 5 events across 3 service(s):

  [2026-05-15T21:59:00.605307+00:00] [AUTH] [INFO] payment_request_received
  [2026-05-15T21:59:00.606008+00:00] [AUTH] [INFO] user_authenticated
  [2026-05-15T21:59:00.617331+00:00] [LEDGER] [INFO] transaction_recorded
  [2026-05-15T21:59:00.630313+00:00] [NOTIFICATION] [INFO] email_send_attempt
  [2026-05-15T21:59:00.685182+00:00] [AUTH] [INFO] payment_complete
</code></pre>
<p>Look at this carefully. The notification is in the timeline, and it logged <code>email_send_attempt</code>. But <code>email_queued</code> is missing, which means the email never actually sent and the error that explains why isn't in the timeline at all. It's hiding in the WARNING at the very top, where the script told you it found a line it couldn't parse.</p>
<p>That's the problem: where the attempt is visible but the failure is invisible.</p>
<p>Run <code>cat logs/notification.log</code> and scroll to the bottom:</p>
<pre><code class="language-plaintext">{"timestamp": "2026-05-15T21:59:00.630313+00:00", "trace_id": "pay-6cf69a8c",
 "service": "notification", "event": "email_send_attempt", ...}
2026-05-15T21:59:00.681583+00:00 ERROR Connection timeout to email provider
http://mock-email:80/ after 0.001s - failed to send confirmation to user@example.com
</code></pre>
<p>Two lines to note: the first has a trace ID, which the script found and showed in the timeline. The second doesn't – the script flagged it as a warning and skipped it. The error happened 0.075 seconds after the attempt. The log file has both lines. The timeline only has one.</p>
<p>That is what <em>"invisible failure"</em> looks like in production. The payment went through. The confirmation email never sent. The error is sitting right there in the log file, <code>Connection timeout to email provider after 0.001s</code> but in the correlation output above, the timeline shows <code>email_send_attempt</code> and then jumps straight to <code>payment_complete</code> with nothing in between: no error, no failure, no gap. It looks like everything worked.</p>
<p>The fix is in <code>02-log-correlation/services/notification/main.py</code>. Here's the broken error handler:</p>
<pre><code class="language-python">except httpx.TimeoutException:
    emit_plain(f"Connection timeout to email provider {EMAIL_PROVIDER_URL}")
    return {"status": "ok"}
</code></pre>
<p>And here's the fixed version. The only change is passing <code>req.trace_id</code> into <code>emit</code> instead of calling <code>emit_plain</code>:</p>
<pre><code class="language-python">except httpx.TimeoutException:
    emit(req.trace_id, "email_timeout", level="ERROR",
         provider=EMAIL_PROVIDER_URL)
    return {"status": "ok"}
</code></pre>
<p>Once that change is made, the timeout error shows up in the timeline like everything else:</p>
<pre><code class="language-plaintext">  [2026-05-15T21:59:00.681583+00:00] [NOTIFICATION] [ERROR] email_timeout
    provider: http://mock-email:80/
</code></pre>
<p>One command, one trace ID, the full picture.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>The correlation script identifies notification as the gap. When you check the raw <code>notification.log</code>, you find the plain-text timeout error, that the request reached the service, that authentication and transaction recording both succeeded, but that the email failed.</p>
<p>Whether a notification failure is a payment failure depends entirely on how your system was designed. If notification is a soft dependency, this error shouldn't have surfaced to the user as a payment failure, and something else in your system design is wrong. If it's a hard dependency, the transaction itself should have rolled back. The script found where things broke, but the right response depends on the design.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<ol>
<li><p>Run <code>./break_it.sh</code> – this switches the notification service to a mode where its error handler drops the trace ID</p>
</li>
<li><p>Run <code>./trigger_request.sh</code> to generate a new payment request and get a new trace ID</p>
</li>
<li><p>Run <code>python correlate.py &lt;new trace ID&gt;</code> – the notification will be missing from the timeline</p>
</li>
<li><p>Run <code>cat logs/notification.log</code> – the timeout error is right there, without a trace ID, invisible to the script</p>
</li>
</ol>
<h2 id="heading-use-case-3-infrastructure-drift-detection">Use Case 3 - Infrastructure Drift Detection</h2>
<p><strong>Environment:</strong> AWS free tier (one security group) + Terraform<br><strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p>Your Terraform plan shows no changes. Your deployment is behaving differently than it did yesterday, and when you ask around, someone eventually remembers: a colleague made a quick manual change to a security group in the AWS console last week to unblock a staging test. They meant to go back and apply it through Terraform but they forgot.</p>
<p>Your Terraform state file and your actual AWS infrastructure have been quietly disagreeing ever since. Not that anything broke loudly or an alert fired. Terraform wouldn't even know unless someone ran <code>terraform plan</code> to check, and in this scenario, nobody did.</p>
<p>This is called infrastructure drift, and it's far more common than most teams want to admit.</p>
<h3 id="heading-whats-actually-happening-at-the-system-level">What's Actually Happening at the System Level</h3>
<p><strong>What this is not:</strong> This isn't the same as running <code>terraform plan</code>. A plan shows you what Terraform <em>would</em> change. This script shows you what has <em>already</em> changed in AWS without Terraform knowing.</p>
<p>The script itself doesn't run any Terraform commands. It reads the state file Terraform already produced. In the demo, Terraform creates that file. In a real environment, it already exists from your normal workflow.</p>
<p>Think of Terraform's state file as a receipt. When Terraform creates a security group, it writes down exactly what it created, the rules, the ports, the CIDRs. That receipt is the state file.</p>
<p>The script compares that receipt against what AWS actually has right now. If someone went into the AWS console and added a rule that isn't on the receipt, the script flags it as drift.</p>
<p>The blind spot is that, if someone creates a completely new security group in the console and never uses Terraform at all, there's no receipt for it. The script can't compare something it has never seen. It returns clean, and that group sits in your account undetected.</p>
<p>The demo shows both. First you break a known resource. Then the <code>--invisible</code> scenario creates a new one outside Terraform entirely, and the script returns clean even though your account now has an extra security group.</p>
<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>03-drift-detection/</code> in the companion repo:</p>
<pre><code class="language-plaintext">cd 03-drift-detection
pip install -r requirements.txt
</code></pre>
<p>Run setup. This uses real Terraform, not a mock:</p>
<pre><code class="language-plaintext">./setup.sh
</code></pre>
<p>This runs <code>terraform init</code> and <code>terraform apply</code>, which creates a real AWS security group:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/d5617704-d04e-40cc-8ca9-aa9d6b806e5e.png" alt="screenshot of AWS dashboard showing security group created" style="display:block;margin:0 auto" width="1189" height="260" loading="lazy">

<p>It also writes a genuine <code>terraform.tfstate</code> file. Open it in any text editor if you want to see what Terraform actually produces. It's JSON, it's readable, and it's the real thing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3ea8e237-a1d7-43bc-be3d-50dcb6ff4b76.png" alt="screenshot of IDE folder structure showing terraform.tfstate file being created" style="display:block;margin:0 auto" width="215" height="230" loading="lazy">

<p>Once setup completes, run the script:</p>
<pre><code class="language-plaintext">python detect_drift.py terraform.tfstate
</code></pre>
<p>You should see something like this, but your actual security group ID will be different:</p>
<pre><code class="language-plaintext">Loading Terraform state from: terraform.tfstate

Checking: sg-0a1b2c3d4e5f6a7b8

  OK - No drift detected.
</code></pre>
<p>The lab is alive and both sides of the contract match. Now let's look at what the script is doing.</p>
<h3 id="heading-the-script-code-files">The Script (<a href="https://github.com/Osomudeya/devops-scripting-labs">Code Files</a>)</h3>
<pre><code class="language-python"># detect_drift.py
import boto3
import json
import sys


def load_tfstate(path):
    """
    The Terraform state file is plain JSON - open it in any text editor
    and you will see a 'resources' array listing everything Terraform knows about.
    This function reads that file and returns the parsed contents.
    """
    with open(path) as f:
        return json.load(f)


def get_security_groups_from_state(tfstate):
    """
    Walk through the resources array and collect every security group entry.
    Each resource has a 'type', a 'name', and an 'instances' array holding
    the attribute values Terraform recorded when it last ran.
    We extract the resource ID and the ingress (inbound) rules.
    """
    resources = {}
    for resource in tfstate.get("resources", []):
        if resource["type"] == "aws_security_group":
            for instance in resource.get("instances", []):
                sg_id = instance["attributes"]["id"]
                resources[sg_id] = {
                    "ingress": instance["attributes"].get("ingress", [])
                }
    return resources


def get_security_group_from_aws(sg_id):
    """
    Call the AWS EC2 API to fetch the live current state of this security group.
    Under the hood, boto3 constructs an authenticated HTTPS request, signs it with
    your AWS credentials, sends it to the EC2 API endpoint in your configured region,
    and parses the response. The response contains far more data than we need -
    we extract only the inbound rules.
    """
    ec2 = boto3.client("ec2")
    response = ec2.describe_security_groups(GroupIds=[sg_id])
    sg = response["SecurityGroups"][0]
    return {"ingress": sg.get("IpPermissions", [])}


def normalize_state_rules(rules):
    """
    Terraform stores ingress rules in its own format.
    We normalize them into a set of tuples for easy comparison.
    Each tuple is: (from_port, to_port, protocol, cidr_block)
    """
    normalized = set()
    for rule in rules:
        for cidr in rule.get("cidr_blocks", []):
            normalized.add((
                rule.get("from_port", 0),
                rule.get("to_port", 0),
                rule.get("protocol", "-1"),
                cidr
            ))
    return normalized


def normalize_aws_rules(rules):
    """
    AWS returns ingress rules in a different format from Terraform's.
    We normalize them into the same tuple shape so the comparison works.
    """
    normalized = set()
    for rule in rules:
        from_port = rule.get("FromPort", 0)
        to_port = rule.get("ToPort", 0)
        protocol = rule.get("IpProtocol", "-1")
        for ip_range in rule.get("IpRanges", []):
            normalized.add((from_port, to_port, protocol, ip_range["CidrIp"]))
    return normalized


def detect_drift(tfstate_path):
    print(f"Loading Terraform state from: {tfstate_path}")
    tfstate = load_tfstate(tfstate_path)
    state_sgs = get_security_groups_from_state(tfstate)

    if not state_sgs:
        print("No security groups found in state file. Nothing to compare.")
        return

    drift_found = False

    for sg_id, state_data in state_sgs.items():
        print(f"\nChecking: {sg_id}")

        try:
            aws_data = get_security_group_from_aws(sg_id)
        except Exception as e:
            print(f"  ERROR: Could not fetch {sg_id} from AWS - {e}")
            print(f"  Check your IAM permissions: ec2:DescribeSecurityGroups is required.")
            continue

        state_rules = normalize_state_rules(state_data["ingress"])
        aws_rules = normalize_aws_rules(aws_data["ingress"])

        # Rules in AWS that Terraform does not know about (manual additions)
        added_in_aws = aws_rules - state_rules
        # Rules Terraform expects that no longer exist in AWS (manual deletions)
        removed_from_aws = state_rules - aws_rules

        if added_in_aws:
            drift_found = True
            print("  DRIFT - Rules present in AWS but missing from state file:")
            for rule in added_in_aws:
                print(f"    Port {rule[0]}-{rule[1]} | Protocol: {rule[2]} | CIDR: {rule[3]}")

        if removed_from_aws:
            drift_found = True
            print("  DRIFT - Rules in state file but removed from AWS:")
            for rule in removed_from_aws:
                print(f"    Port {rule[0]}-{rule[1]} | Protocol: {rule[2]} | CIDR: {rule[3]}")

        if not added_in_aws and not removed_from_aws:
            print("  OK - No drift detected.")

    print("\n" + "=" * 60)
    if drift_found:
        print("Drift detected. See above for details.")
    else:
        print("No drift detected in monitored resources.")

    print("\nIMPORTANT: This script only checks resources tracked in your state file.")
    print("Resources created manually in AWS without Terraform are invisible to this check.")
    print("A clean output here does not mean your AWS account is clean - it means")
    print("the resources you are watching match what Terraform last recorded.")


if __name__ == "__main__":
    tfstate_path = sys.argv[1] if len(sys.argv) &gt; 1 else "terraform.tfstate"
    detect_drift(tfstate_path)
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>load_tfstate</code> opens <code>terraform.tfstate</code> and reads it. Run <code>cat terraform.tfstate</code> after setup and you'll see that it's just a text file and everything Terraform knows about your infrastructure is stored in there.</p>
<p><code>get_security_groups_from_state</code> pulls out every security group from that file, the ID AWS assigned it, and the inbound rules Terraform last recorded. These are the expected values.</p>
<p><code>get_security_group_from_aws</code> calls the AWS API and fetches the same security group's current inbound rules. These are the actual values. The script now has two versions of the same thing.</p>
<p><code>normalize_state_rules</code> and <code>normalize_aws_rules</code> exist because Terraform and AWS store the same rule in slightly different formats. These two functions convert both into the same format so the comparison works.</p>
<p>The comparison is the last step. Rules in AWS but not in the state file were added manually. Rules in the state file but not in AWS were deleted manually. The script prints both.</p>
<h3 id="heading-what-the-output-looks-like">What the Output Looks Like</h3>
<p>A clean run with no drift:</p>
<pre><code class="language-plaintext">Loading Terraform state from: terraform.tfstate

Checking: sg-0a1b2c3d4e5f6a7b8

  OK - No drift detected.

============================================================
No drift detected in monitored resources.

IMPORTANT: This script only checks resources tracked in your state file.
Resources created manually in AWS without Terraform are invisible to this check.
A clean output here does not mean your AWS account is clean - it means
the resources you are watching match what Terraform last recorded.
</code></pre>
<p>After injecting drift:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/61ae6f66-36e4-4e03-8e76-f250e2489dab.png" alt="screenshot of AWS dashboard showing security group inbound rule created" style="display:block;margin:0 auto" width="1170" height="220" loading="lazy">

<pre><code class="language-plaintext">Loading Terraform state from: terraform.tfstate

Checking: sg-0a1b2c3d4e5f6a7b8

  DRIFT - Rules present in AWS but missing from state file:
    Port 22-22 | Protocol: tcp | CIDR: 0.0.0.0/0

============================================================
Drift detected. See above for details.
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5744ac0c-2e4f-4015-9b72-a1fa7084587e.png" alt="screenshot of terminal output after injecting drift showing &quot;drift detected&quot;" style="display:block;margin:0 auto" width="952" height="183" loading="lazy">

<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>The script finds drift, an inbound rule that Terraform doesn't know about. The instinct is to revert it immediately by running <code>terraform apply</code>, but before doing that, ask one question: was this change an emergency hotfix? Someone may have manually opened a port at 2am to restore a broken service while a proper fix was being prepared. And if you revert it automatically, you might undo something that was deliberately placed there to keep a service running.</p>
<p>Drift detection tells you that things are different. It doesn't tell you which version is correct, and investigating that is the work that comes after the script runs.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<ol>
<li><p>Run <code>./break_it.sh</code>. This adds an SSH inbound rule (port 22) directly via the AWS CLI, simulating a manual console change.</p>
</li>
<li><p>Run <code>python detect_drift.py terraform.tfstate</code>. The drift appears in the output.</p>
</li>
<li><p>Run <code>./break_it.sh --invisible</code> to create a brand new security group that's not in the state file at all, then run the script again. It returns clean even though a new resource exists in your account, making the coverage gap visible.</p>
</li>
<li><p>Run <code>./teardown.sh</code>. When finished, this runs <code>terraform destroy</code> to delete the security group and clean up all AWS resources. No charges will remain after this.</p>
</li>
</ol>
<h2 id="heading-use-case-4-secrets-rotation-with-zero-downtime">Use Case 4 - Secrets Rotation with Zero Downtime</h2>
<p><strong>Environment:</strong> AWS Secrets Manager + local Kind cluster<br><strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p><strong>The goal of this use case:</strong> Kubernetes says a pod is healthy, but your users are getting database errors. The script catches that gap before the users are affected by running one extra check that Kubernetes never runs.</p>
<p>You rotate your database credentials. The pod restarts. <code>kubectl get pods</code> shows Running. Ten minutes later, users can't log in.</p>
<p>The rotation worked, but the problem is that Kubernetes checked whether the HTTP server was alive, not whether it could authenticate with the database. Those are two different things.</p>
<h3 id="heading-whats-actually-happening">What's Actually Happening</h3>
<p><strong>What this is not:</strong> This isn't about how to store secrets in Kubernetes. It's about what happens after the secret is rotated.</p>
<p>When a pod is already running, it holds a pool of open database connections that were authenticated before the rotation happened. Those connections stay alive after the password changes because they were authenticated before the change and the database does not kick them out. But when the pool needs to open a new connection, it uses the current environment credentials, which still have the old password. That new connection fails immediately.</p>
<p>Meanwhile, Kubernetes sees the pod responding to HTTP and marks it Running, so your users are hitting the failures with no indication from the cluster that anything is wrong.</p>
<h3 id="heading-what-the-healthzdb-endpoint-does">What the <code>/healthz/db</code> Endpoint Does</h3>
<p><code>/healthz</code> returns 200 if the HTTP server is alive. That is all Kubernetes checks.</p>
<p><code>/healthz/db</code> opens a fresh database connection using the current credentials and runs <code>SELECT 1</code>. If that fails after a rotation, the pod is Running but can't serve database requests. The rotation script calls this endpoint as its final step – the check Kubernetes never runs.</p>
<p>Here's what that looks like in the demo FastAPI application (<a href="https://github.com/Osomudeya/devops-scripting-labs">code files</a>):</p>
<pre><code class="language-python"># app.py (relevant section)
import os
import asyncpg
from fastapi import FastAPI, HTTPException

app = FastAPI()

DB_HOST = os.environ.get("DB_HOST", "postgres")
DB_PORT = int(os.environ.get("DB_PORT", "5432"))
DB_NAME = os.environ.get("DB_NAME", "appdb")
DB_USERNAME = os.environ.get("DB_USERNAME", "appuser")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")

@app.get("/healthz")
async def healthz():
    # Always returns 200 if the HTTP server is alive.
    # This is all the Kubernetes readiness probe checks.
    return {"status": "ok"}

@app.get("/healthz/db")
async def healthz_db():
    # Opens a fresh connection using the current environment credentials.
    # If the password was rotated and this pod has not restarted yet,
    # the environment still has the old password - this connection fails.
    # /healthz above would still return 200. Your users would see errors.
    try:
        conn = await asyncpg.connect(
            host=DB_HOST, port=DB_PORT,
            database=DB_NAME, user=DB_USERNAME, password=DB_PASSWORD,
        )
        await conn.execute("SELECT 1")
        await conn.close()
        return {"status": "ok", "db": "authenticated"}

    except asyncpg.InvalidPasswordError:
        raise HTTPException(
            status_code=503,
            detail=(
                f"Authentication failed for '{DB_USERNAME}'. "
                "Password may have been rotated. "
                "Readiness probe does not check this."
            )
        )
    except Exception as e:
        raise HTTPException(status_code=503, detail=f"Database error: {str(e)}")
</code></pre>
<p>The difference between these two endpoints is the entire lesson of this use case.</p>
<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>04-secrets-rotation/</code> and run the setup script:</p>
<pre><code class="language-plaintext">cd 04-secrets-rotation
./setup.sh
</code></pre>
<p>This starts a Kind cluster, deploys real PostgreSQL with the <code>appuser</code> account already created, deploys the demo FastAPI app connected to it, and creates an initial secret in AWS Secrets Manager.</p>
<p>Once setup completes, install the dependencies:</p>
<pre><code class="language-plaintext">pip install boto3 kubernetes
</code></pre>
<p>Before running the rotation, confirm everything is running:</p>
<pre><code class="language-plaintext">kubectl get pods
</code></pre>
<p>You should see <code>myapp</code> and <code>postgres</code> pods both in the Running state. If any pod shows Pending or Error, wait 30 seconds and check again. PostgreSQL takes a moment to finish initialising.</p>
<p>You can also verify that the secret was created in AWS. In the console, go to AWS Secrets Manager and look for <code>myapp/db-credentials</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c5d481f7-c938-43f8-91ec-09640c137897.png" alt="screenshot showing AWS secret created" style="display:block;margin:0 auto" width="1414" height="337" loading="lazy">

<p>If you prefer the CLI:</p>
<pre><code class="language-plaintext">aws secretsmanager get-secret-value --secret-id myapp/db-credentials
</code></pre>
<p>Once both pods are Running and the secret exists, run the rotation to see the full path:</p>
<pre><code class="language-plaintext">python rotate_secret.py
</code></pre>
<p><strong>If Step 6 shows FAILED on this first clean run</strong>, it's almost always a timing issue: the app pod restarted successfully but <code>/healthz/db</code> ran before the new pod finished establishing its first database connection. Wait 20 seconds and run <code>python rotate_secret.py</code> again. If it fails repeatedly, run <code>kubectl logs deployment/myapp</code> to see what the app is reporting.</p>
<p>You should see all six steps complete cleanly, ending with:</p>
<pre><code class="language-plaintext">Rotation complete. Credential verified at the application level.
  AWS Secrets Manager: updated
  PostgreSQL:          updated (ALTER USER)
  Kubernetes Secret:   updated
  Application pod:     restarted, authenticated
</code></pre>
<p>The lab is alive and the full rotation chain works end to end. Now let's look at what the script is doing.</p>
<h3 id="heading-the-script-code-files">The Script (<a href="https://github.com/Osomudeya/devops-scripting-labs">Code Files</a>)</h3>
<pre><code class="language-python"># rotate_secret.py
import boto3
import base64
import json
import subprocess
import sys
from kubernetes import client, config


def get_current_secret(secret_name):
    """
    Fetch the current credential from AWS Secrets Manager.
    The secret is stored as a JSON string with 'username' and 'password' fields.
    """
    sm = boto3.client("secretsmanager")
    response = sm.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])


def rotate_in_aws(secret_name, username, new_password):
    """
    Write the new credential to AWS Secrets Manager.
    put_secret_value creates a new version - the previous version is
    not deleted immediately, giving you a short rollback window.
    """
    sm = boto3.client("secretsmanager")
    new_value = json.dumps({"username": username, "password": new_password})
    sm.put_secret_value(SecretId=secret_name, SecretString=new_value)
    print("  [AWS] Secret updated in Secrets Manager.")


def update_kubernetes_secret(namespace, k8s_secret_name, username, new_password):
    """
    Patch the Kubernetes Secret object with the new credential values.
    Kubernetes requires secret data to be base64-encoded - this is encoding,
    not encryption. Anyone with access to the Secret object can decode the values.
    Real encryption at rest requires separate etcd encryption configuration.
    """
    config.load_kube_config()
    v1 = client.CoreV1Api()

    secret_data = {
        "username": base64.b64encode(username.encode()).decode(),
        "password": base64.b64encode(new_password.encode()).decode()
    }

    v1.patch_namespaced_secret(
        name=k8s_secret_name,
        namespace=namespace,
        body={"data": secret_data}
    )
    print(f"  [K8s] Kubernetes Secret '{k8s_secret_name}' updated.")


def rolling_restart(namespace, deployment_name):
    """
    Trigger a rolling restart of the deployment.
    Rolling restart means Kubernetes creates one new pod, waits for it to pass
    its readiness probe, then terminates one old pod - and repeats until all
    pods have been replaced. Availability is preserved throughout.
    This is very different from deleting all pods at once.
    """
    result = subprocess.run(
        ["kubectl", "rollout", "restart",
         f"deployment/{deployment_name}", "-n", namespace],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(f"Rolling restart failed: {result.stderr}")
    print(f"  [K8s] Rolling restart triggered for '{deployment_name}'.")


def wait_for_rollout(namespace, deployment_name, timeout=120):
    """
    Block until the rolling restart finishes or times out.
    'Finished' means all new pods are Running and their readiness probes passed.
    This does NOT mean the application can authenticate with the new credential.
    That is what verify_credential checks next.
    """
    print(f"  [K8s] Waiting for rollout (timeout: {timeout}s)...")
    result = subprocess.run(
        ["kubectl", "rollout", "status",
         f"deployment/{deployment_name}",
         "-n", namespace,
         f"--timeout={timeout}s"],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(f"Rollout did not complete: {result.stderr}")
    print("  [K8s] Rollout complete. All pods report Ready.")


def verify_credential(namespace, deployment_name):
    """
    This is the check the readiness probe does not make.
    We exec into the running pod and call /healthz/db - an endpoint that
    makes an actual authenticated query to the database.
    If this passes: the credential is working at the application level.
    If this fails after the readiness probe passed: the contract mismatch is confirmed.
    The pod is Running. The application cannot serve database requests.
    """
    print("  [Verify] Running post-rotation credential check...")

    result = subprocess.run(
        ["kubectl", "get", "pods", "-n", namespace,
         "-l", f"app={deployment_name}",
         "-o", "jsonpath={.items[0].metadata.name}"],
        capture_output=True, text=True
    )
    pod_name = result.stdout.strip()

    if not pod_name:
        print("  [Verify] ERROR: No running pod found for this deployment.")
        return False

    verify = subprocess.run(
        ["kubectl", "exec", pod_name, "-n", namespace,
         "--", "curl", "-sf", "http://localhost:8000/healthz/db"],
        capture_output=True, text=True
    )

    if verify.returncode != 0:
        print("  [Verify] FAILED - Pod is Running but database authentication failed.")
        print("           The readiness probe validated HTTP reachability.")
        print("           The application cannot authenticate with the new credential.")
        print("           These are two different contracts. Only one was checked automatically.")
        return False

    print("  [Verify] PASSED - Application confirmed it can authenticate with the new credential.")
    return True


def rotate(secret_name, new_password, namespace, k8s_secret_name, deployment_name):
    print("\n[Step 1/6] Reading current secret from AWS Secrets Manager...")
    current = get_current_secret(secret_name)
    username = current["username"]

    print("[Step 2/6] Updating AWS Secrets Manager...")
    rotate_in_aws(secret_name, username, new_password)

    print("[Step 3/6] Rotating password at the database level (ALTER USER)...")
    rotate_postgres_password(namespace, new_password)

    print("[Step 4/6] Updating Kubernetes Secret object...")
    update_kubernetes_secret(namespace, k8s_secret_name, username, new_password)

    print("[Step 5/6] Triggering rolling restart...")
    rolling_restart(namespace, deployment_name)
    wait_for_rollout(namespace, deployment_name)

    print("[Step 6/6] Verifying the new credential works at the application level...")
    success = verify_credential(namespace, deployment_name)

    print("\n" + "=" * 60)
    if success:
        print("Rotation complete. Credential verified at the application level.")
    else:
        print("Rotation incomplete. Readiness probe passed but credential verification failed.")
        print("Recommended action: force-restart all pods to flush the connection pool,")
        print("or investigate the database session timeout configuration.")
        sys.exit(1)


if __name__ == "__main__":
    import secrets as _secrets
    rotate(
        secret_name="myapp/db-credentials",
        new_password=_secrets.token_urlsafe(16),
        namespace="default",
        k8s_secret_name="db-credentials",
        deployment_name="myapp"
    )
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>get_current_secret</code> reads the current credential from AWS Secrets Manager so the script knows the username before it generates a new password.</p>
<p><code>rotate_in_aws</code> writes the new credential to Secrets Manager. It creates a new version rather than overwriting the old one, so you have a short window to roll back if something goes wrong.</p>
<p><code>_pg_password_literal</code> and <code>rotate_postgres_password</code> handle the step that most rotation scripts skip, which is actually changing the password inside PostgreSQL. This is done by running <code>ALTER USER appuser PASSWORD '...'</code> directly on the live PostgreSQL pod. Before this step, the database still accepts the old password. After this step, it does not.</p>
<p><code>update_kubernetes_secret</code> writes the new password into the Kubernetes Secret so that any new pod that starts will get the new credential from the beginning.</p>
<p><code>rolling_restart</code> and <code>wait_for_rollout</code> restart the application pods one at a time so the deployment stays available throughout. When this step completes, all pods are Running and their readiness probes have passed – but keep in mind that "Running" only means <code>/healthz</code> returned 200, which is exactly the problem this use case is about.</p>
<p><code>verify_credential</code> is the extra step Kubernetes never runs. It reaches inside the new pod and calls <code>/healthz/db</code>, which opens a real database connection with the credentials in the pod's current environment. If this passes, the rotation is genuinely complete. If this fails after the readiness probe passed, you have confirmed the gap: the pod looks healthy but can't serve database requests.</p>
<h3 id="heading-what-the-output-looks-like">What the Output Looks Like</h3>
<p>Successful rotation:</p>
<pre><code class="language-plaintext">[Step 1/6] Reading current secret from AWS Secrets Manager...
[Step 2/6] Updating AWS Secrets Manager...
  [AWS] Secrets Manager updated.
[Step 3/6] Rotating password at the database level (ALTER USER)...
  [DB]  Running ALTER USER on PostgreSQL...
  [DB]  Password changed at the database level.
        New connections now require the new password.
        Existing pool connections remain valid until they close.
[Step 4/6] Updating Kubernetes Secret object...
  [K8s] Kubernetes Secret 'db-credentials' updated.
[Step 5/6] Triggering rolling restart...
  [K8s] Rolling restart triggered for 'myapp'.
  [K8s] Waiting for rollout (timeout: 120s)...
  [K8s] Rollout complete. All pods report Ready.
[Step 6/6] Verifying the new credential works at the application level...
  [Verify] Running post-rotation credential check...
  [Verify] PASSED - Application confirmed it can authenticate with the new credential.

============================================================
Rotation complete. Credential verified at the application level.
  AWS Secrets Manager: updated
  PostgreSQL:          updated (ALTER USER)
  Kubernetes Secret:   updated
  Application pod:     restarted, authenticated
</code></pre>
<p>The lab is alive and the full rotation chain works end to end.</p>
<p>Before you break anything, confirm the pod is healthy:</p>
<pre><code class="language-plaintext">kubectl get pods
</code></pre>
<p>You should see <code>myapp</code> in Running state. That is the baseline: everything working as expected. Now let's break it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/caabd892-562a-40f4-81c1-2c59fd15240b.png" alt="terminal screenshot showing output of 'kubectl get pods&quot;" style="display:block;margin:0 auto" width="753" height="187" loading="lazy">

<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<h4 id="heading-step-1-desync-the-db">Step 1: Desync the DB</h4>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>This runs <code>ALTER USER</code> directly on PostgreSQL with a wrong password. The K8s Secret still has the old password, so the pod's environment and the database are now out of sync.</p>
<h4 id="heading-step-2-check-what-kubernetes-sees">Step 2: Check what Kubernetes sees</h4>
<pre><code class="language-plaintext">kubectl exec deployment/myapp -- curl -s http://localhost:8000/healthz
</code></pre>
<p>You will see <code>{"status":"ok"}</code>. The pod is still showing Ready in <code>kubectl get pods</code>. Kubernetes has no idea anything is wrong – that's the contract gap made visible in your terminal.</p>
<h4 id="heading-step-3-check-what-your-users-experience">Step 3: Check what your users experience</h4>
<pre><code class="language-plaintext">kubectl exec deployment/myapp -- curl -s http://localhost:8000/healthz/db
</code></pre>
<p>You'll see a <code>503</code> error. Fresh database connections are failing. Your users are already seeing this.</p>
<h4 id="heading-step-4-see-the-mixed-pattern-optional">Step 4: See the mixed pattern (optional)</h4>
<pre><code class="language-plaintext">./load_test.sh
</code></pre>
<p>Some requests succeed because they hit old pool connections that were authenticated before the break. Some fail because they need a fresh connection. The pod looks healthy, but half your traffic is failing.</p>
<h4 id="heading-step-5-run-the-rotation-script">Step 5: Run the rotation script</h4>
<pre><code class="language-plaintext">python rotate_secret.py
</code></pre>
<p>This time, Step 6 catches the failure. Here's what you'll see:</p>
<pre><code class="language-plaintext">[Step 5/6] Triggering rolling restart...
  [K8s] Rollout complete. All pods report Ready.
[Step 6/6] Verifying the new credential works at the application level...
  [Verify] Running post-rotation credential check...
  [Verify] FAILED - Pod is Running but database authentication failed.
           The readiness probe validated HTTP reachability.
           The application cannot authenticate with the new credential.
           These are two different contracts. Only one was checked automatically.

============================================================
Rotation incomplete. Readiness probe passed but credential verification failed.
</code></pre>
<p>The pod is Running and shows Ready in <code>kubectl get pods</code>. The rotation script says the credential is broken. That's the contract gap visible in your terminal, caught before your users hit it.</p>
<p><strong>The lesson:</strong> <code>/healthz</code> tells you the HTTP server is alive. <code>/healthz/db</code> tells you the application can actually connect to the database. Kubernetes only checks the first one unless you add a database probe. The rotation script adds that check at the end of every rotation so you catch the failure before your users do.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>The verification failed, the pod is Running, and requests to the database are failing. You have two options:</p>
<ol>
<li><p>force-restart all pods at once to flush the connection pool (which is faster but causes a brief capacity reduction), or</p>
</li>
<li><p>wait for old sessions to expire naturally (which avoids downtime but leaves requests failing intermittently until the pool cycles).</p>
</li>
</ol>
<p>The script found the problem, but deciding what to do next belongs to an engineer who knows the system.</p>
<h3 id="heading-teardown">Teardown</h3>
<pre><code class="language-plaintext">./teardown.sh
</code></pre>
<h2 id="heading-use-case-5-automated-canary-rollback-trigger">Use Case 5 - Automated Canary Rollback Trigger</h2>
<p><strong>Environment:</strong> Fully local – Kind, Prometheus via Helm<br><strong>Language:</strong> Bash</p>
<h3 id="heading-what-this-use-case-does-and-why-it-matters">What This Use Case Does and Why it Matters</h3>
<p>This use case runs a script that watches your new deployment and automatically rolls it back if something goes wrong, before your users flood your support queue.</p>
<p>This matters in production because, when you ship a new version, you don't send all traffic to it immediately. You send a small slice, say 20% to the new version while 80% still goes to the old one. If the new version is broken, only 20% of users are affected and you can roll back before the damage spreads. But the rollback only works if you're watching the right things.</p>
<p><strong>The takehome:</strong> Two scripts watch the same failing canary. One reports everything is fine. The other fires the rollback. The only difference is what they measure. Your automation is only as good as what it watches.</p>
<p><strong>What to watch for:</strong> <code>canary_watch_v1.sh</code> watches errors only and stays silent while the canary is slow. <code>canary_watch_v2.sh</code> watches errors AND response time and fires the rollback. The difference between them is the lesson.</p>
<p><strong>What this is not:</strong> This isn't a guide to canary deployments. It's about what your monitoring misses when it only watches one signal.</p>
<h3 id="heading-how-it-works">How it Works</h3>
<p>Three things run in the cluster: the stable app (three pods, handles most traffic), the canary app (one pod, handles a small slice), and Prometheus (collects response times and error counts from both every 15 seconds).</p>
<p>The watch script asks Prometheus every 15 seconds: <em>"Is the canary behaving normally?"</em> If the answer is no for three checks in a row, it rolls back the canary automatically.</p>
<p>The question is that what does <em>"behaving normally"</em> mean? That is the entire use case.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/204f992e-44c2-4404-a6ff-f2279ea23aeb.png" alt="terminal screenshot showing output result of 'kubectl get pods&quot;" style="display:block;margin:0 auto" width="753" height="187" loading="lazy">

<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>05-canary-rollback/</code> and run:</p>
<pre><code class="language-plaintext">cd 05-canary-rollback
./setup.sh
</code></pre>
<p>Setup takes a few minutes. It installs Prometheus, deploys both versions of the demo app, and starts a load generator pod that sends continuous traffic to both so Prometheus always has data.</p>
<p>When setup finishes, confirm everything is running:</p>
<pre><code class="language-plaintext">kubectl get pods
</code></pre>
<p>You should see output like this:</p>
<pre><code class="language-plaintext">NAME                                                   READY   STATUS    RESTARTS   AGE
load-generator-68c59698b7-kws2l                        1/1     Running   0          4m54s
myapp-canary-6d6979c66f-g9lgw                          1/1     Running   0          32s
myapp-stable-6bcf994fc4-b4k9l                          1/1     Running   0          4m55s
myapp-stable-6bcf994fc4-ndhxc                          1/1     Running   0          4m55s
myapp-stable-6bcf994fc4-z97kx                          1/1     Running   0          4m55s
prometheus-kube-prometheus-operator-59b847d96c-mp72s   1/1     Running   0          5m58s
prometheus-prometheus-kube-prometheus-prometheus-0     2/2     Running   0          5m1s
</code></pre>
<p>Three stable pods, one canary pod, one load generator, Prometheus running. The lab is alive.</p>
<p><strong>Wait 60 seconds before running anything else.</strong> Prometheus needs time to scrape the first metrics from the pods. If you skip this, the watch scripts return empty data with no explanation.</p>
<h3 id="heading-three-terminal-windows">Three Terminal Windows</h3>
<p>You need three separate command prompts running at the same time.</p>
<p><strong>On macOS:</strong> open Terminal and press <code>Cmd+T</code> twice. You now have three tabs, each an independent terminal.<br><strong>On Linux:</strong> press <code>Ctrl+Shift+T</code> in most terminal apps, or right-click and choose "Open new tab."</p>
<p>Label them Terminal 1 for the watch script, Terminal 2 for injecting failures, Terminal 3 for watching latency.</p>
<h3 id="heading-the-scripts">The Scripts</h3>
<h4 id="heading-version-1-watches-errors-only-code-here">Version 1: watches errors only (<a href="https://github.com/Osomudeya/devops-scripting-labs.git">code here</a>)</h4>
<pre><code class="language-bash">#!/usr/bin/env bash
# canary_watch_v1.sh

PROMETHEUS="http://localhost:9090"
DEPLOYMENT="myapp-canary"
NAMESPACE="default"
ERROR_THRESHOLD="0.05"
CHECK_INTERVAL=15
STRIKE_LIMIT=3

strikes=0

echo "Canary monitor running (v1 - error rate only)."
echo "Rollback triggers if error rate exceeds \({ERROR_THRESHOLD} for \){STRIKE_LIMIT} checks."
echo ""

while true; do
    ts=$(date '+%Y-%m-%dT%H:%M:%S')

    error_query='sum(rate(http_requests_total{app="myapp-canary",status=~"5.."}[1m])) / sum(rate(http_requests_total{app="myapp-canary"}[1m]))'

    error_rate=\((curl -sf "\){PROMETHEUS}/api/v1/query" \
        --data-urlencode "query=${error_query}" | \
        python3 -c "
import sys, json
d = json.load(sys.stdin)
result = d['data']['result']
print(result[0]['value'][1] if result else '0')
" 2&gt;/dev/null)

    error_rate=${error_rate:-0}
    above=\((echo "\)error_rate &gt; $ERROR_THRESHOLD" | bc -l)

    echo "[\(ts] error_rate=\){error_rate} | threshold=\({ERROR_THRESHOLD} | breach=\)([ "$above" = "1" ] &amp;&amp; echo YES || echo NO)"

    if [ "$above" = "1" ]; then
        strikes=$((strikes + 1))
        echo "  Strike \({strikes}/\){STRIKE_LIMIT}"
        if [ "\(strikes" -ge "\)STRIKE_LIMIT" ]; then
            echo "  ROLLBACK TRIGGERED"
            kubectl rollout undo deployment/"\({DEPLOYMENT}" -n "\){NAMESPACE}"
            exit 0
        fi
    else
        strikes=0
    fi

    sleep "${CHECK_INTERVAL}"
done
</code></pre>
<h4 id="heading-version-2-watches-error-rate-and-response-time">Version 2: watches error rate AND response time</h4>
<pre><code class="language-bash">#!/usr/bin/env bash
# canary_watch_v2.sh

PROMETHEUS="http://localhost:9090"
DEPLOYMENT="myapp-canary"
NAMESPACE="default"
ERROR_THRESHOLD="0.05"
LATENCY_THRESHOLD="2.0"
CHECK_INTERVAL=15
STRIKE_LIMIT=3

strikes=0

echo "Canary monitor running (v2 - error rate + P99 latency)."
echo "Error threshold: \({ERROR_THRESHOLD} | Latency P99 threshold: \){LATENCY_THRESHOLD}s"
echo ""

while true; do
    ts=$(date '+%Y-%m-%dT%H:%M:%S')

    error_query='sum(rate(http_requests_total{app="myapp-canary",status=~"5.."}[1m])) / sum(rate(http_requests_total{app="myapp-canary"}[1m]))'
    error_rate=\((curl -sf "\){PROMETHEUS}/api/v1/query" \
        --data-urlencode "query=${error_query}" | \
        python3 -c "
import sys, json
d = json.load(sys.stdin)
result = d['data']['result']
print(result[0]['value'][1] if result else '0')
" 2&gt;/dev/null)

    latency_query='histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{app="myapp-canary"}[1m])) by (le))'
    latency=\((curl -sf "\){PROMETHEUS}/api/v1/query" \
        --data-urlencode "query=${latency_query}" | \
        python3 -c "
import sys, json
d = json.load(sys.stdin)
result = d['data']['result']
print(result[0]['value'][1] if result else '0')
" 2&gt;/dev/null)

    error_rate=${error_rate:-0}
    latency=${latency:-0}

    error_breach=\((echo "\)error_rate &gt; $ERROR_THRESHOLD" | bc -l)
    latency_breach=\((echo "\)latency &gt; $LATENCY_THRESHOLD" | bc -l)

    triggered_by=""
    [ "\(error_breach" = "1" ] &amp;&amp; triggered_by="error_rate(\){error_rate})"
    [ "\(latency_breach" = "1" ] &amp;&amp; triggered_by="\){triggered_by:+\({triggered_by}, }latency_p99(\){latency}s)"

    echo "[\(ts] error_rate=\){error_rate} | latency_p99=\({latency}s | breach=\){triggered_by:-none}"

    if [ "\(error_breach" = "1" ] || [ "\)latency_breach" = "1" ]; then
        strikes=$((strikes + 1))
        echo "  Strike \({strikes}/\){STRIKE_LIMIT} | Triggered by: ${triggered_by}"
        if [ "\(strikes" -ge "\)STRIKE_LIMIT" ]; then
            echo ""
            echo "  ROLLBACK TRIGGERED"
            echo "  Signal: ${triggered_by}"
            kubectl rollout undo deployment/"\({DEPLOYMENT}" -n "\){NAMESPACE}"
            exit 0
        fi
    else
        strikes=0
    fi

    sleep "${CHECK_INTERVAL}"
done
</code></pre>
<h3 id="heading-how-the-scripts-work">How the Scripts Work</h3>
<p>The <code>error rate query</code> asks Prometheus: <em>"What fraction of requests to the canary returned an error in the last minute?"</em> A result of <code>0.0</code> means no errors. A result of <code>0.06</code> means 6% of requests are failing, above the 5% threshold. You see this in the output as:</p>
<pre><code class="language-plaintext">error_rate=0.06 | threshold=0.05 | breach=YES
</code></pre>
<p>The <code>latency query</code> asks: <em>"How slow is the slowest 1% of requests to the canary right now?"</em> A result of <code>5.234</code> means 1 in every 100 requests is taking over 5 seconds. You see this as:</p>
<pre><code class="language-plaintext">latency_p99=5.234s | breach=latency_p99(5.234s)
</code></pre>
<p>V1 only runs the first query. V2 runs both. Same canary, same problem, different answers.</p>
<p>The three-strike rule means a single bad check doesn't trigger a rollback – three in a row does. The tradeoff is 45 seconds (three checks at 15 seconds each) of exposure before the rollback fires.</p>
<p>When three strikes hit, the watch script itself runs:</p>
<pre><code class="language-plaintext">kubectl rollout undo deployment/myapp-canary -n default
</code></pre>
<p>That one line is what triggers the rollback. It lives inside <code>canary_watch_v2.sh</code> and runs automatically – you don't have to do anything. The script detects, decides, and acts.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<p><strong>In Terminal 1</strong>, start the v1 monitor:</p>
<pre><code class="language-plaintext">./canary_watch_v1.sh
</code></pre>
<p>You will see this repeating every 15 seconds:</p>
<pre><code class="language-plaintext">Canary monitor running (v1 - error rate only).
Rollback triggers if error rate exceeds 0.05 for 3 checks.

[2026-05-17T11:53:12] error_rate=0 | threshold=0.05 | breach=NO
[2026-05-17T11:53:27] error_rate=0 | threshold=0.05 | breach=NO
[2026-05-17T11:53:42] error_rate=0 | threshold=0.05 | breach=NO
</code></pre>
<p><code>breach=NO</code> means the canary looks healthy. Leave this running and move to Terminal 2.</p>
<p><strong>In Terminal 2</strong>, inject latency into the canary:</p>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>This makes every request to the canary take 5 seconds. Requests still return 200 – no errors, just slowness. You will see:</p>
<pre><code class="language-plaintext">Injecting latency into the canary deployment...
deployment "myapp-canary" successfully rolled out
Latency injection is active.

The canary pod is Running and passing its readiness probe.
Every request to the canary now takes 5 seconds.
Error rate: 0%   |   P99 latency: ~5s
</code></pre>
<p>Now look back at Terminal 1. The v1 monitor keeps printing <code>breach=NO</code>. The canary is taking 5 seconds per request and your monitoring says everything is fine. That's the failure.</p>
<p><strong>In Terminal 3</strong>, see what your users are actually experiencing:</p>
<pre><code class="language-plaintext">./check_latency.sh
</code></pre>
<pre><code class="language-plaintext">TIMESTAMP                   STABLE (ms)   CANARY (ms)   STATUS
---------                   -----------   -----------   ------
2026-05-17T11:55:14         18ms          5008ms        CANARY DEGRADED
2026-05-17T11:55:20         7ms           5008ms        CANARY DEGRADED
2026-05-17T11:55:27         6ms           5008ms        CANARY DEGRADED
</code></pre>
<p>Stable is responding in 6–18 milliseconds. Canary is taking over 5 seconds. Users on the canary are waiting 5 seconds for every page load. The v1 monitor in Terminal 1 still says <code>breach=NO</code>.</p>
<p>This is the lesson: the monitoring and the user experience are completely disconnected. The script isn't broken. It's watching the wrong thing.</p>
<p>Now let's see the fix. Press <code>Ctrl+C</code> in Terminal 1 to stop v1. Start v2 in the same terminal:</p>
<pre><code class="language-plaintext">./canary_watch_v2.sh
</code></pre>
<p>In Terminal 2, re-inject the latency:</p>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>Watch Terminal 1. V2 catches the latency and fires the rollback after three strikes:</p>
<pre><code class="language-plaintext">Canary monitor running (v2 - error rate + P99 latency).
Error threshold: 0.05 | Latency P99 threshold: 2.0s

[2026-05-15T14:30:00] error_rate=0.0 | latency_p99=0.082s | breach=none
[2026-05-15T14:30:15] error_rate=0.0 | latency_p99=5.234s | breach=latency_p99(5.234s)
  Strike 1/3 | Triggered by: latency_p99(5.234s)
[2026-05-15T14:30:30] error_rate=0.0 | latency_p99=5.891s | breach=latency_p99(5.891s)
  Strike 2/3 | Triggered by: latency_p99(5.891s)
[2026-05-15T14:30:45] error_rate=0.0 | latency_p99=6.102s | breach=latency_p99(6.102s)
  Strike 3/3 | Triggered by: latency_p99(6.102s)

  ROLLBACK TRIGGERED
  Signal: latency_p99(6.102s)

deployment.apps/myapp-canary rolled back
</code></pre>
<p>The error rate never moved from 0. V2 rolled back anyway because latency crossed the threshold. That's the difference one extra measurement makes.</p>
<p>After the rollback, confirm the canary is dormant but not deleted:</p>
<pre><code class="language-plaintext">kubectl rollout history deployment/myapp-canary -n default
</code></pre>
<pre><code class="language-plaintext">REVISION  CHANGE-CAUSE
1         &lt;none&gt;
2         &lt;none&gt;
</code></pre>
<p>Two revisions. The rollback scaled revision 2 down to zero and restored revision 1. Nothing was deleted, and you can re-deploy if you decide the rollback was a false alarm.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>V2 rolled back based on latency with zero errors. Before re-deploying, ask if the latency was a real regression in the new code, or a temporary spike, like a database cache warming up on first use? Both produce the same signal. Only you know which is more likely given what changed.</p>
<p>False positive rollbacks slow down deployments and erode confidence in automation. The right thresholds depend on your users and your system.<br>What the script enforces is whatever you configure.</p>
<h3 id="heading-teardown">Teardown</h3>
<pre><code class="language-plaintext">./teardown.sh
</code></pre>
<h2 id="heading-what-you-can-do-now">What You Can Do Now</h2>
<p>Each use case in this handbook was a script solving a specific problem the standard tooling wasn't catching. Here's where you land:</p>
<p>You can catch AWS cost spikes before the invoice and you know that the service label is AWS's attribution, not a pointer to what actually caused the cost. Start from what changed operationally, not from the billing label.</p>
<p>You can reconstruct the full timeline of any failed request across multiple services from a single trace ID, and you know that a missing service in that timeline is evidence, not just an absence.</p>
<p>You can detect infrastructure drift by comparing what Terraform believes against what AWS actually contains, and you know that a clean result means the resources Terraform manages are in sync, not that your entire AWS account is clean.</p>
<p>You can validate a secret rotation at the application level, not just at the infrastructure level, and you know the difference between a readiness probe passing and the application actually being able to connect to the database.</p>
<p>You can build a canary rollback trigger that watches the right signals, and you know why watching only error rates can leave a slow, broken deployment running while users wait.</p>
<p>The pattern across all five use cases is the same: the standard tooling reported everything as fine while something was actually broken. The cost script returned clean, the pod showed Running, and the canary showed zero errors – not because the tools were wrong but because they were only checking what was easy to check. These scripts check what the standard tooling skips.</p>
<p><strong>GitHub repo:</strong> <a href="https://github.com/Osomudeya/devops-scripting-labs.git">https://github.com/Osomudeya/devops-scripting-labs</a></p>
<p>I write about DevOps weekly, covering real systems, interview, CV tips and tricks, and real incidents – <a href="https://osomudeya.kit.com/23db7ca59f"><strong>Join the newsletter</strong></a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Connect Your AI Coding Agent to a Browser on macOS  ]]>
                </title>
                <description>
                    <![CDATA[ AI coding agents like Claude Code, Cursor, and the rest have gotten remarkably good at reading and writing code. But the moment they need to look at something on the web, they hit a wall. They can't s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/</link>
                <guid isPermaLink="false">6a1594c1da253d50d4ae1277</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ macOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ אחיה כהן ]]>
                </dc:creator>
                <pubDate>Tue, 26 May 2026 12:40:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/7e77f1c5-6942-4dbe-a3c6-ca74cc4354e5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>AI coding agents like Claude Code, Cursor, and the rest have gotten remarkably good at reading and writing code. But the moment they need to <em>look at something on the web</em>, they hit a wall. They can't see your staging site. They can't read the error in your analytics dashboard. They can't check whether the form they just built actually submits.</p>
<p>The usual fix is to hand the agent a headless browser — Puppeteer or Playwright driving a fresh Chromium instance. That works, sort of. But a headless Chromium starts every session as a stranger: no logins, no cookies, no sessions. It spins up a second browser engine that pushes your CPU and spins up your fan. And a growing number of sites simply block it on sight.</p>
<p>There's another option, and on a Mac it's a good one: let the agent drive the <strong>Safari you already use</strong> — the one that's already logged into GitHub, your analytics, your staging environment. That's what Safari MCP does. It's an open-source MCP server that exposes Safari to any MCP-capable agent through around 80 tools, with no Chromium, no WebDriver, and no separate browser to babysit.</p>
<p>In this tutorial you'll connect Safari MCP to an AI agent, run your first automation, and then build something a headless browser fundamentally cannot do: an automation that works inside a page you're logged into. By the end you'll understand not just <em>how</em> to wire this up, but <em>when</em> native browser automation is the right call — and when it isn't.</p>
<p>Here's what you'll need:</p>
<ul>
<li><p>A Mac (Safari MCP is macOS-only — more on that trade-off later)</p>
</li>
<li><p>Node.js 18 or newer</p>
</li>
<li><p>An MCP-capable AI agent — this tutorial uses Claude Code and Cursor, but any MCP client works</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-mcp-and-why-does-browser-automation-need-it">What is MCP, and Why Does Browser Automation Need It?</a></p>
</li>
<li><p><a href="#heading-why-safari-instead-of-chrome-or-playwright">Why Safari Instead of Chrome or Playwright?</a></p>
</li>
<li><p><a href="#heading-installing-safari-mcp">Installing Safari MCP</a></p>
</li>
<li><p><a href="#heading-your-first-automation-reading-a-page">Your First Automation: Reading a Page</a></p>
</li>
<li><p><a href="#heading-the-payoff-automating-a-logged-in-workflow">The Payoff: Automating a Logged-in Workflow</a></p>
</li>
<li><p><a href="#heading-handling-the-tricky-parts">Handling the Tricky Parts</a></p>
</li>
<li><p><a href="#heading-limitations-when-not-to-use-this">Limitations: When Not to Use This</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-is-mcp-and-why-does-browser-automation-need-it">What is MCP, and Why Does Browser Automation Need It?</h2>
<p>Before wiring anything up, it helps to know what the "MCP" in Safari MCP stands for.</p>
<p><strong>MCP</strong> is the Model Context Protocol — an open standard for connecting AI agents to external tools and data. Think of it the way you'd think of a USB port. Before USB, every device needed its own connector. MCP is the equivalent of agreeing on one connector: an agent that speaks MCP can use <em>any</em> tool that speaks MCP, with no custom integration code on either side.</p>
<p>An MCP <strong>server</strong> exposes a set of tools. An MCP <strong>client</strong> — your AI agent — discovers those tools and calls them. The server describes each tool (its name, what it does, what arguments it takes) and the agent decides when to call it. When Claude Code decides it needs to read a web page, it doesn't run browser code itself. It calls a tool that some MCP server provides.</p>
<p>Browser automation is a natural fit for this model. The agent's job is reasoning — "I need to see what's on the staging site, then check the console for errors." The actual mechanics — open a tab, wait for load, read the DOM, capture console output — are well-defined operations that belong behind a stable interface. That interface is exactly what an MCP server provides.</p>
<p>Safari MCP is one such server. It runs as a local process, exposes around 80 browser tools (navigate, click, fill, read, screenshot, extract, and more), and any MCP client can drive it. The agent never touches AppleScript or WebKit internals. It just calls <code>safari_navigate</code> and gets a result.</p>
<p>The "USB port" framing matters for a practical reason: nothing in this tutorial is Claude-specific. Wire Safari MCP into Cursor, Cline, Windsurf, or your own MCP client and the tools are identical.</p>
<h2 id="heading-why-safari-instead-of-chrome-or-playwright">Why Safari Instead of Chrome or Playwright?</h2>
<p>If you've automated a browser before, you've almost certainly used Chrome through Puppeteer, Playwright, or Selenium. So why reach for Safari?</p>
<p>It comes down to three differences that matter once an <em>AI agent</em>, not a test script, is the thing driving the browser.</p>
<p><strong>1. It's your real browser, with your real sessions.</strong> A headless Chromium launched by Playwright is a clean room. It has never logged into anything. If you want your agent to read your analytics dashboard, you first have to solve authentication — store credentials somewhere, script the login, handle two-factor prompts, refresh tokens. Safari MCP skips all of that. It drives the Safari instance you use every day, which is <em>already</em> logged into your dashboards, your GitHub, your email. The agent inherits those sessions for free.</p>
<p><strong>2. It doesn't melt your laptop.</strong> A headless Chromium is a second, full browser engine running alongside the browser you already have open. On a laptop that's real CPU, real memory, and a fan you can hear. Safari MCP uses the WebKit engine that's already running on every Mac — there's no second engine to start. The project measures this at roughly 60% less CPU for the browsing work, and the automation runs with Safari in the background, so it doesn't steal your screen.</p>
<p><strong>3. Sites don't treat it as a bot.</strong> Headless browsers leak. They expose <code>navigator.webdriver</code>, they ship with telltale automation fingerprints, and bot-detection services — Cloudflare's challenge pages, reCAPTCHA, the WAFs in front of a lot of B2B sites — have gotten very good at spotting them. Your real Safari, driven through the operating system, looks like exactly what it is: a person's browser. (To be clear: this is for automating <em>your own</em> accounts and sites — not for evading access controls you don't own.)</p>
<p>The cost of all this is the obvious one: <strong>Safari MCP is macOS-only.</strong> It's built on WebKit and AppleScript, so there's no Windows or Linux story. If your agent runs on a Linux CI box, this isn't your tool. If it runs on your Mac — which, for a coding agent, it very often does — the trade is a good one. We'll come back to limitations honestly at the end.</p>
<h2 id="heading-installing-safari-mcp">Installing Safari MCP</h2>
<p>Installation is genuinely one command, but there are two Safari settings to flip first. Let's do it in order.</p>
<h3 id="heading-step-1-enable-safaris-developer-features">Step 1 — Enable Safari's developer features</h3>
<p>Safari MCP reads and controls pages by running JavaScript inside Safari. Two settings have to be on:</p>
<ol>
<li><p>Open <strong>Safari → Settings → Advanced</strong> and check <strong>"Show features for web developers."</strong> This reveals the Develop menu.</p>
</li>
<li><p>Open the new <strong>Develop</strong> menu and check <strong>"Allow JavaScript from Apple Events."</strong></p>
</li>
</ol>
<p>That second one is the important one. It's what lets an outside process — the MCP server — ask Safari to run JavaScript on a page. Without it, every tool call fails.</p>
<h3 id="heading-step-2-run-the-server">Step 2 — Run the server</h3>
<pre><code class="language-bash">npx safari-mcp
</code></pre>
<p>That's the whole install. <code>npx</code> fetches the package and runs it; there's nothing to build. The first time an agent calls a tool, macOS will pop up a permission prompt — something like <em>"Terminal wants to control Safari."</em> Click <strong>OK</strong>. That's the standard Automation permission, and you can review it later under <strong>System Settings → Privacy &amp; Security → Automation</strong>.</p>
<p>If you'd rather have it installed permanently:</p>
<pre><code class="language-bash">npm install -g safari-mcp
</code></pre>
<h3 id="heading-step-3-tell-your-agent-about-it">Step 3 — Tell your agent about it</h3>
<p>Your AI agent needs to know the server exists. For <strong>Claude Code</strong>, one command does it:</p>
<pre><code class="language-bash">claude mcp add safari -- npx safari-mcp
</code></pre>
<p>For <strong>Cursor</strong>, create <code>.cursor/mcp.json</code> in your project:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "safari": {
      "command": "npx",
      "args": ["safari-mcp"]
    }
  }
}
</code></pre>
<p>The process is the same for every client — Claude Desktop, Cline, Windsurf, Continue, VS Code. You're telling the agent: "there's an MCP server named <code>safari</code>; start it by running <code>npx safari-mcp</code>."</p>
<p>Restart your agent (or reload its MCP servers) and it will connect. In Claude Code you can confirm with the <code>/mcp</code> command, which lists connected servers and their tools. You should see <code>safari</code> with around 80 tools available.</p>
<p>That's it. Your agent now has a browser.</p>
<h2 id="heading-your-first-automation-reading-a-page">Your First Automation: Reading a Page</h2>
<p>Let's prove the wiring works with the simplest possible task: have the agent read a web page.</p>
<p>In your agent, just ask in plain language:</p>
<blockquote>
<p>"Use the safari tools to open example.com and tell me what the page says."</p>
</blockquote>
<p>Behind that request, the agent makes two tool calls. First it navigates:</p>
<pre><code class="language-json">{ "tool": "safari_navigate", "arguments": { "url": "https://example.com" } }
</code></pre>
<p>Then it reads the content:</p>
<pre><code class="language-json">{ "tool": "safari_read_page", "arguments": {} }
</code></pre>
<p><code>safari_read_page</code> returns the page's title, URL, and text content with the HTML stripped out — exactly the form an LLM wants. The agent gets back something like this:</p>
<pre><code class="language-plaintext">Example Domain
https://example.com/
This domain is for use in illustrative examples in documents. You may
use this domain in literature without prior coordination or asking for
permission.
</code></pre>
<p>And it relays that to you. You just watched your agent browse.</p>
<p>A quick note on <em>how</em> the agent should look at a page, because it changes everything downstream. <code>safari_read_page</code> is great for "what does this say." But when the agent needs to <em>act</em> — click a button, fill a field — text isn't enough. It needs to know what's actually there and how to target it. For that, the better first move is <code>safari_snapshot</code>:</p>
<pre><code class="language-json">{ "tool": "safari_snapshot", "arguments": {} }
</code></pre>
<p>This returns an accessibility-tree view of the page, where every interactive element has a stable <code>ref</code> ID:</p>
<pre><code class="language-plaintext">[textbox ref=0_8] "Full Name" value=""
[combobox ref=0_10] "Subject"
[button ref=0_15] "Submit"
</code></pre>
<p>Those <code>ref</code> IDs are the agent's reliable handles. CSS selectors break when a page re-renders. A snapshot ref stays valid for the life of the page. Keep that in mind — it's the difference between an automation that works once and one that works every time.</p>
<h2 id="heading-the-payoff-automating-a-logged-in-workflow">The Payoff: Automating a Logged-in Workflow</h2>
<p>Reading example.com is a wiring test. Here's the thing a headless browser genuinely cannot do.</p>
<p>Pick a site you're logged into in Safari right now — your analytics, your project board, your CI dashboard. We'll use GitHub, because every developer has an account and the notifications page is a real, mildly annoying chore. The task: <strong>have the agent open your GitHub notifications and summarize what actually needs your attention.</strong></p>
<p>Ask the agent:</p>
<blockquote>
<p>"Open my GitHub notifications, read them, and group them into 'needs a reply' versus 'just FYI'."</p>
</blockquote>
<p>The agent navigates:</p>
<pre><code class="language-json">{ "tool": "safari_navigate", "arguments": { "url": "https://github.com/notifications" } }
</code></pre>
<p>Stop and notice what <em>didn't</em> happen. No login screen. No OAuth dance. No personal access token in an environment variable. Safari is already authenticated as you, so the agent lands directly on your real notifications. A headless Chromium would have hit a login wall here and stopped.</p>
<p>Notification lists load incrementally, so the agent should wait for content before reading. <code>safari_wait_for</code> polls the page until a selector or piece of text appears, or a timeout elapses:</p>
<pre><code class="language-json">{ "tool": "safari_wait_for", "arguments": { "text": "Inbox", "timeout": 10000 } }
</code></pre>
<p>Then it reads. <code>safari_read_page</code> scoped to the notifications region returns the list as clean text:</p>
<pre><code class="language-json">{ "tool": "safari_read_page", "arguments": { "selector": "main" } }
</code></pre>
<p>The agent reasons over that text and hands you the grouped summary. The whole loop — navigate, wait, read, summarize — is a handful of tool calls.</p>
<p>When you need data in a precise shape rather than prose — to feed another step, or to write to a file — the agent can reach for <code>safari_evaluate</code>, which runs custom JavaScript on the page and returns whatever you build:</p>
<pre><code class="language-json">{
  "tool": "safari_evaluate",
  "arguments": {
    "expression": "JSON.stringify([...document.querySelectorAll('li')].map(li =&gt; li.innerText.trim()))"
  }
}
</code></pre>
<p>The agent writes that expression itself, against the structure it just saw in the snapshot — you don't hand-author selectors.</p>
<p>You might be thinking: <em>GitHub has an API, why scrape the page?</em> Fair. For GitHub specifically, the API is excellent. But the point generalizes. Most of the dashboards you stare at every day — your billing portal, your error tracker's specific filtered view, a client's analytics, the admin panel of some tool your company pays for — either have no usable API or would cost you an afternoon of OAuth setup to reach. With Safari MCP, "the page I'm already looking at" <em>is</em> the API. The agent reads what you can see, because it's using the browser you're seeing it in.</p>
<p>That's the capability headless automation can't match. Not speed, not features — <strong>access.</strong></p>
<h2 id="heading-handling-the-tricky-parts">Handling the Tricky Parts</h2>
<p>A first automation always looks easy. Three things tend to bite on the second one.</p>
<h3 id="heading-tab-safety-the-agent-must-not-hijack-your-tabs">Tab Safety — The Agent Must not Hijack Your Tabs</h3>
<p>This is the scariest failure mode: you're typing in a tab, the agent navigates <em>that</em> tab, and your work is gone. Safari MCP guards against it by stamping each automation tab with an identity marker — it uses <code>window.name</code>, which survives page navigations — and resolving "the agent's tab" through that marker on every call. If it can't positively identify its own tab, it refuses to act and raises a re-anchor error rather than guessing.</p>
<p>The practical rule for you: let the agent open its own tab with <code>safari_new_tab</code>, and it will stay in its lane. Don't point it at "the current tab" and assume.</p>
<h3 id="heading-waiting-for-dynamic-content">Waiting for Dynamic Content</h3>
<p>Modern pages render after load. If the agent reads too early, it reads an empty shell. Don't have it guess with fixed sleeps — use <code>safari_wait_for</code>, which polls for a selector or text until it appears or the timeout elapses:</p>
<pre><code class="language-json">{ "tool": "safari_wait_for", "arguments": { "selector": ".results-list", "timeout": 8000 } }
</code></pre>
<p>This is the single most common fix for "the automation works when I step through it slowly but fails when it runs."</p>
<h3 id="heading-framework-forms">Framework Forms</h3>
<p>Set a React or Vue input's <code>.value</code> directly and the framework never notices — its internal state stays empty, and your "filled" form submits blank. Safari MCP's <code>safari_fill</code> and <code>safari_fill_form</code> use the native value setters and dispatch the <code>input</code> and <code>change</code> events the framework listens for, so React, Vue, Angular, and Svelte state all stay in sync:</p>
<pre><code class="language-json">{
  "tool": "safari_fill_form",
  "arguments": {
    "fields": [
      { "selector": "#email", "value": "jane@example.com" },
      { "selector": "#message", "value": "Looks great." }
    ]
  }
}
</code></pre>
<p>For framework-heavy pages where CSS selectors are fragile, go back to the snapshot refs from the previous section — pass <code>{ "ref": "0_9" }</code> instead of <code>{ "selector": "#email" }</code>. Refs survive re-renders; selectors don't.</p>
<p>None of these are exotic. They're just the difference between a demo and an automation you'd actually leave running.</p>
<h2 id="heading-limitations-when-not-to-use-this">Limitations: When Not to Use This</h2>
<p>A tool tutorial that only lists strengths isn't worth much. Here's where Safari MCP is the wrong choice.</p>
<p><strong>It's macOS-only, and that's structural.</strong> Safari MCP is built on WebKit and AppleScript. There's no Windows or Linux port coming, because the foundation doesn't exist on those platforms. If your agent runs in Linux CI, use Playwright.</p>
<p><strong>It drives one Safari, on one Mac.</strong> This is browser automation for <em>your</em> machine — a coding agent working alongside you. It is not a fleet. If you need 50 parallel browsers scraping in a data center, that's a headless-Chromium-in-containers job, and Safari MCP is the wrong shape for it.</p>
<p><strong>Cross-browser test suites should stay on Playwright.</strong> If you're writing end-to-end tests that must pass on Chrome, Firefox, and Safari, use the tool built for that. Safari MCP drives exactly one engine: WebKit.</p>
<p><strong>It shares a browser with you.</strong> Because it uses your real Safari, the agent and you are in the same browser. That's the entire point — but it means you should let the agent work in its own tabs and not fight it for the same window.</p>
<p>The honest summary: Safari MCP is built for one specific situation — an AI agent doing real browser work on the Mac you're sitting at, against sites you're already logged into. In that situation it's hard to beat. Outside it, reach for the headless tools. Knowing which situation you're in is the actual skill.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>You've gone from an AI agent that could only see code to one that can see the web — the real web, behind your real logins.</p>
<p>To recap what you did: you learned what MCP is and why browser automation belongs behind that interface. You saw why a native Safari engine beats a headless Chromium for an agent working on your Mac and you installed Safari MCP with one command and two settings. You ran a first read, and then you did the thing that actually matters — an automation inside a logged-in page, with no auth code at all. Finally, you saw the edges: tab safety, waiting for dynamic content, framework forms, and the cases where you should pick a different tool.</p>
<p>The bigger idea is worth holding onto. An AI agent is only as capable as the tools you connect to it. Giving it a browser — a <em>real</em> one — turns "write me code" into "go look at the staging site, find the bug, and tell me what's wrong." That's a different kind of collaborator.</p>
<p>Safari MCP is open source under the MIT license, and it exposes around 80 tools beyond the handful you used here — screenshots, network inspection, storage, accessibility audits, multi-tab workflows. The repository and full tool reference are at <a href="https://github.com/achiya-automation/safari-mcp">github.com/achiya-automation/safari-mcp</a>. Point your agent at it and see what it does when it can finally look around.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Self-Hosted WhatsApp Bot with n8n and WAHA ]]>
                </title>
                <description>
                    <![CDATA[ WhatsApp is where your many of your customers likely already are. For support tickets, order updates, booking reminders, and lead qualification, a WhatsApp channel often converts several times better  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-self-hosted-whatsapp-bot-with-n8n-and-waha/</link>
                <guid isPermaLink="false">6a01e032fca21b0d4b2bb4c1</guid>
                
                    <category>
                        <![CDATA[ whatsapp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ n8n ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ self-hosted ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ אחיה כהן ]]>
                </dc:creator>
                <pubDate>Mon, 11 May 2026 13:57:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/28affe4d-9359-4cbb-a311-a2ee9d0829c0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>WhatsApp is where your many of your customers likely already are. For support tickets, order updates, booking reminders, and lead qualification, a WhatsApp channel often converts several times better than email.</p>
<p>But the official WhatsApp Business Cloud API can be slow to onboard, template-restricted for proactive messages, and priced per conversation — which adds up fast at scale.</p>
<p>There's another path: you can run your own WhatsApp HTTP gateway on a small server, connect it to a workflow engine, and keep every message — inbound and outbound — inside infrastructure you control. No monthly conversation fees, no template approvals for routine replies, no third-party middleman holding your customer data.</p>
<p>In this tutorial, you'll build exactly that. By the end, you'll have a WhatsApp bot that:</p>
<ul>
<li><p>Receives every incoming message through a webhook</p>
</li>
<li><p>Routes messages through an n8n workflow</p>
</li>
<li><p>Replies automatically based on keywords, AI, or any API call you want</p>
</li>
<li><p>Runs entirely on your own server, using two open-source tools</p>
</li>
</ul>
<p>You'll use <strong>WAHA</strong> (WhatsApp HTTP API) as the gateway, and <strong>n8n</strong> as the workflow engine. Both run in Docker, both are free for self-hosting, and together they cover everything from a simple auto-reply to a full CRM integration.</p>
<h2 id="heading-table-of-contents">Table of contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-a-note-on-which-whatsapp-account-to-use">A Note on Which WhatsApp Account to Use</a></p>
</li>
<li><p><a href="#heading-waha-vs-the-official-whatsapp-business-cloud-api">WAHA vs the official WhatsApp Business Cloud API</a></p>
</li>
<li><p><a href="#heading-part-1-understanding-waha">Part 1: Understanding WAHA</a></p>
</li>
<li><p><a href="#heading-part-2-running-waha-with-docker">Part 2: Running WAHA with Docker</a></p>
</li>
<li><p><a href="#heading-part-3-starting-a-whatsapp-session">Part 3: Starting a WhatsApp session</a></p>
</li>
<li><p><a href="#heading-part-4-running-n8n">Part 4: Running n8n</a></p>
</li>
<li><p><a href="#heading-part-5-creating-the-webhook-trigger-in-n8n">Part 5: Creating the Webhook Trigger in n8n</a></p>
</li>
<li><p><a href="#heading-part-6-wiring-waha-to-n8n">Part 6: Wiring WAHA to n8n</a></p>
</li>
<li><p><a href="#heading-part-7-building-the-first-auto-reply">Part 7: Building the first auto-reply</a></p>
</li>
<li><p><a href="#heading-part-8-a-second-example-proactive-booking-confirmations">Part 8: A Second Example — Proactive Booking Confirmations</a></p>
</li>
<li><p><a href="#heading-part-9-going-to-production">Part 9: Going to Production</a></p>
</li>
<li><p><a href="#heading-common-pitfalls">Common Pitfalls</a></p>
</li>
<li><p><a href="#heading-where-to-go-next">Where to Go Next</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>How WAHA works under the hood and when to use it instead of the official Cloud API</p>
</li>
<li><p>How to run WAHA and n8n side by side with Docker Compose</p>
</li>
<li><p>How to scan the QR code and bind a WhatsApp account to your gateway</p>
</li>
<li><p>How to connect WAHA's webhook to an n8n workflow</p>
</li>
<li><p>How to build a keyword-based auto-reply bot</p>
</li>
<li><p>How to send proactive confirmations from a separate workflow</p>
</li>
<li><p>How to harden the setup for production (HTTPS, API keys, rate limits, Queue Mode)</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>A Linux server (any VPS works — 2 GB of RAM is enough for a small bot)</p>
</li>
<li><p>Docker and Docker Compose installed</p>
</li>
<li><p>A public hostname with DNS pointing at the server, or an ngrok tunnel for local testing</p>
</li>
<li><p>A WhatsApp account you're willing to dedicate to the bot (more on that below)</p>
</li>
<li><p>Basic familiarity with JSON and HTTP requests</p>
</li>
</ul>
<p>You don't need prior n8n experience. If you can drag a box and wire it to another box, you can build the flow.</p>
<h2 id="heading-a-note-on-which-whatsapp-account-to-use">A Note on Which WhatsApp Account to Use</h2>
<p>WAHA works by running an actual WhatsApp Web session inside a headless Chromium process. It logs in as a real account — the same way you would open web.whatsapp.com in your browser. Meta doesn't officially endorse this approach for commercial use at scale, and heavy volume from a single number can lead to a ban.</p>
<p>For that reason, use a dedicated number for the bot. Don't use your personal WhatsApp. Get a second SIM, eSIM, or a VoIP number that supports WhatsApp activation. Keep outbound volume reasonable, and you'll be fine for most small-business use cases.</p>
<p>If you plan to send thousands of marketing messages per day, switch to the official WhatsApp Business Cloud API — that's what it exists for. This tutorial is aimed at the middle ground: support bots, order updates, booking confirmations, and similar conversational flows where you need real-time control without enterprise pricing.</p>
<h2 id="heading-waha-vs-the-official-whatsapp-business-cloud-api">WAHA vs the official WhatsApp Business Cloud API</h2>
<p>Before writing any code, it helps to understand when each option is the right fit.</p>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>WAHA (self-hosted)</th>
<th>WhatsApp Cloud API (Meta)</th>
</tr>
</thead>
<tbody><tr>
<td>Onboarding</td>
<td>Scan a QR code — ready in minutes</td>
<td>Business verification, app review — days to weeks</td>
</tr>
<tr>
<td>Cost</td>
<td>Server cost only</td>
<td>Per-conversation pricing</td>
</tr>
<tr>
<td>Template approval</td>
<td>Not needed</td>
<td>Required for proactive messages outside the 24-hour window</td>
</tr>
<tr>
<td>Session model</td>
<td>One WhatsApp Web session per Core container</td>
<td>Native API, no web session</td>
</tr>
<tr>
<td>Risk</td>
<td>Account ban possible at high unsolicited volume</td>
<td>Rate limits but no ban for normal use</td>
</tr>
<tr>
<td>Vendor lock-in</td>
<td>None — pure open source</td>
<td>Tied to Meta's API and pricing</td>
</tr>
<tr>
<td>Best for</td>
<td>Support bots, small-team workflows, internal tools</td>
<td>High-volume marketing, regulated industries, &gt;100k monthly messages</td>
</tr>
</tbody></table>
<p>Neither is strictly better. If you run a support team for a small business, WAHA is often the pragmatic choice. If you're a bank sending millions of transactional messages, you want the Cloud API. Many teams run both — WAHA for conversational support, Cloud API for bulk transactional traffic.</p>
<h2 id="heading-part-1-understanding-waha">Part 1: Understanding WAHA</h2>
<p>WAHA is an open-source project that wraps WhatsApp Web behind a clean REST API. You <code>POST /api/sendText</code> with a chat ID and a message, and WAHA sends it. You configure a webhook URL, and WAHA <code>POST</code>s to that URL every time a message arrives.</p>
<p>Under the hood, WAHA spawns a Chromium instance, opens WhatsApp Web, and uses an engine (<code>whatsapp-web.js</code>, <code>NOWEB</code>, or <code>GOWS</code>) to automate the session. Your code doesn't see any of that complexity — you just see an HTTP API.</p>
<p>The project ships in two flavors:</p>
<ul>
<li><p><strong>WAHA Core</strong> — free, MIT licensed, one active session per container, community support.</p>
</li>
<li><p><strong>WAHA Plus</strong> — commercial license, multi-session support, priority support, and access to advanced endpoints.</p>
</li>
</ul>
<p>For most developers building a single bot, Core is enough. You can always upgrade later.</p>
<p>Official docs live at <a href="https://waha.devlike.pro/">waha.devlike.pro</a>. Keep that open in another tab — we'll reference specific endpoints as we go.</p>
<h2 id="heading-part-2-running-waha-with-docker">Part 2: Running WAHA with Docker</h2>
<p>Create a fresh directory for the project:</p>
<pre><code class="language-bash">mkdir whatsapp-bot &amp;&amp; cd whatsapp-bot
</code></pre>
<p>Create a <code>docker-compose.yml</code> file:</p>
<pre><code class="language-yaml">services:
  waha:
    image: devlikeapro/waha:latest
    container_name: waha
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - WAHA_DASHBOARD_ENABLED=true
      - WAHA_DASHBOARD_USERNAME=admin
      - WAHA_DASHBOARD_PASSWORD=change-me-now
      - WHATSAPP_API_KEY=super-secret-key-change-me
      - WHATSAPP_DEFAULT_ENGINE=WEBJS
    volumes:
      - ./waha-sessions:/app/.sessions
</code></pre>
<p>A few things to notice:</p>
<ul>
<li><p>The dashboard username and password protect the web UI at <code>http://your-server:3000</code>. Always change the defaults before you expose the port publicly.</p>
</li>
<li><p><code>WHATSAPP_API_KEY</code> is the key every HTTP request to WAHA must include in the <code>X-Api-Key</code> header. Treat it like a database password.</p>
</li>
<li><p><code>WHATSAPP_DEFAULT_ENGINE=WEBJS</code> uses the mature <code>whatsapp-web.js</code> engine. WAHA also supports <code>NOWEB</code> and <code>GOWS</code> engines with different trade-offs — WEBJS is the safest default for a first deployment.</p>
</li>
<li><p>The volume mount persists the session across restarts. Without it, every container rebuild forces you to scan the QR code again.</p>
</li>
</ul>
<p>Start the container:</p>
<pre><code class="language-bash">docker compose up -d
docker compose logs -f waha
</code></pre>
<p>Within about 20 seconds WAHA finishes booting. Visit <code>http://your-server:3000</code> and log in with the dashboard credentials.</p>
<h2 id="heading-part-3-starting-a-whatsapp-session">Part 3: Starting a WhatsApp session</h2>
<p>WAHA calls each WhatsApp account a "session." You can have one session at a time on WAHA Core.</p>
<p>From the dashboard, click <strong>Start New Session</strong> and name it <code>default</code>. WAHA displays a QR code.</p>
<p>On your phone:</p>
<ol>
<li><p>Open WhatsApp.</p>
</li>
<li><p>Tap the three-dot menu (Android) or Settings (iOS).</p>
</li>
<li><p>Tap Linked Devices → Link a Device.</p>
</li>
<li><p>Point the camera at the QR code on your screen.</p>
</li>
</ol>
<p>Within a few seconds the dashboard shows <code>WORKING</code> status. Your session is live.</p>
<p>You can also do this over the API. Start the session (<code>default</code> is the session name, encoded in the URL path):</p>
<pre><code class="language-bash">curl -X POST http://your-server:3000/api/sessions/default/start \
  -H "X-Api-Key: super-secret-key-change-me"
</code></pre>
<p>The call is idempotent — if the session is already running, nothing happens.</p>
<p>Fetch the QR as a PNG:</p>
<pre><code class="language-bash">curl http://your-server:3000/api/default/auth/qr \
  -H "X-Api-Key: super-secret-key-change-me" \
  -H "Accept: image/png" \
  --output qr.png
</code></pre>
<p>Scan and you're in.</p>
<p>Test that the session works by sending a message to yourself:</p>
<pre><code class="language-bash">curl -X POST http://your-server:3000/api/sendText \
  -H "X-Api-Key: super-secret-key-change-me" \
  -H "Content-Type: application/json" \
  -d '{
    "session": "default",
    "chatId": "15555550123@c.us",
    "text": "Hello from WAHA!"
  }'
</code></pre>
<p>Replace <code>15555550123</code> with your own number (country code plus number, no <code>+</code>, no spaces, no dashes). The <code>@c.us</code> suffix marks it as an individual chat. Groups use <code>@g.us</code>.</p>
<p>If the message lands on your phone — congratulations. The gateway works.</p>
<h2 id="heading-part-4-running-n8n">Part 4: Running n8n</h2>
<p>Add an <code>n8n</code> service to your <code>docker-compose.yml</code> alongside WAHA:</p>
<pre><code class="language-yaml">services:
  waha:
    # ... existing config

  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=n8n.example.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.example.com/
      - GENERIC_TIMEZONE=UTC
    volumes:
      - ./n8n-data:/home/node/.n8n
</code></pre>
<p>Replace <code>n8n.example.com</code> with your real domain. For purely local testing, set:</p>
<pre><code class="language-yaml">- N8N_HOST=localhost
- N8N_PROTOCOL=http
- WEBHOOK_URL=http://localhost:5678/
</code></pre>
<p>If you want to test webhooks from your laptop without a server, run <code>ngrok http 5678</code> in another terminal and use the ngrok HTTPS URL as <code>WEBHOOK_URL</code>. n8n uses <code>WEBHOOK_URL</code> to tell external services where to POST — get this wrong and your webhooks will 404.</p>
<p>Start the stack:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p>Visit <code>http://your-server:5678</code>. On the first visit, n8n walks you through creating an owner account (email and password). Every subsequent visit requires that login. For extra safety in production, put n8n behind a reverse proxy with an allow-list or an additional auth layer — we'll set that up later.</p>
<h2 id="heading-part-5-creating-the-webhook-trigger-in-n8n">Part 5: Creating the Webhook Trigger in n8n</h2>
<p>Click Create Workflow. You'll see an empty canvas.</p>
<p>Add a Webhook node and configure it:</p>
<ul>
<li><p><strong>HTTP Method</strong>: POST</p>
</li>
<li><p><strong>Path</strong>: <code>whatsapp</code> (this becomes part of the URL)</p>
</li>
<li><p><strong>Response Mode</strong>: Respond Immediately</p>
</li>
<li><p><strong>Response Data</strong>: First Entry JSON</p>
</li>
</ul>
<p>Click Listen for Test Event. n8n shows you two URLs: a test URL and a production URL. Copy the production URL. It looks like this:</p>
<pre><code class="language-plaintext">https://n8n.example.com/webhook/whatsapp
</code></pre>
<p>Not <code>webhook-test</code> — that one only fires while the editor is open. You want <code>webhook</code>.</p>
<h2 id="heading-part-6-wiring-waha-to-n8n">Part 6: Wiring WAHA to n8n</h2>
<p>WAHA can POST to a webhook on every WhatsApp event. Tell it where to send those events.</p>
<p>In the WAHA dashboard, open your session and set the webhook URL. Or do it over the API:</p>
<pre><code class="language-bash">curl -X PUT http://your-server:3000/api/sessions/default \
  -H "X-Api-Key: super-secret-key-change-me" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "webhooks": [
        {
          "url": "https://n8n.example.com/webhook/whatsapp",
          "events": ["message", "session.status"]
        }
      ]
    }
  }'
</code></pre>
<p>The <code>message</code> event fires on every inbound message. <code>session.status</code> fires when the session connects, disconnects, or reconnects — which is useful for alerting when your bot goes down.</p>
<p>Test it. From another phone, send a WhatsApp message to your bot's number. Head back to the n8n editor. Within a second or two the webhook node lights up with the event data.</p>
<p>The payload looks roughly like this:</p>
<pre><code class="language-json">{
  "event": "message",
  "session": "default",
  "payload": {
    "id": "false_15555550123@c.us_3EB0...",
    "from": "15555550123@c.us",
    "body": "Hello",
    "timestamp": 1713801234,
    "fromMe": false
  }
}
</code></pre>
<p>Everything you need is in <code>payload</code>: who sent it (<code>from</code>), what they said (<code>body</code>), and when (<code>timestamp</code>).</p>
<h2 id="heading-part-7-building-the-first-auto-reply">Part 7: Building the first auto-reply</h2>
<p>A bot that only listens is boring. Let's make it answer.</p>
<p>You'll build a tiny keyword router: if the user sends <code>hi</code> or <code>hello</code>, the bot greets them. If they send <code>price</code>, it sends a pricing message. Anything else gets a fallback.</p>
<p>After the Webhook node, add a Switch node.</p>
<p>Configure the Switch node:</p>
<ul>
<li><p><strong>Mode</strong>: Expression</p>
</li>
<li><p><strong>Value</strong>: <code>{{ $json.payload.body.toLowerCase().trim() }}</code></p>
</li>
<li><p>Add routing rules:</p>
<ul>
<li><p>Rule 1: equals <code>hi</code> — output 0</p>
</li>
<li><p>Rule 2: equals <code>hello</code> — output 0</p>
</li>
<li><p>Rule 3: equals <code>price</code> — output 1</p>
</li>
<li><p>Fallback output: 2</p>
</li>
</ul>
</li>
</ul>
<p>After the Switch, add three HTTP Request nodes, one per output.</p>
<p>Configure each HTTP Request node identically, except for the body text:</p>
<ul>
<li><p><strong>Method</strong>: POST</p>
</li>
<li><p><strong>URL</strong>: <code>http://waha:3000/api/sendText</code> (inside the Docker network you can reach WAHA by its service name. From outside use the full public URL)</p>
</li>
<li><p><strong>Send Headers</strong>: on</p>
<ul>
<li><p><code>X-Api-Key</code>: <code>super-secret-key-change-me</code></p>
</li>
<li><p><code>Content-Type</code>: <code>application/json</code></p>
</li>
</ul>
</li>
<li><p><strong>Send Body</strong>: on</p>
<ul>
<li><p><strong>Body Content Type</strong>: JSON</p>
</li>
<li><p><strong>Specify Body</strong>: Using JSON</p>
</li>
</ul>
</li>
</ul>
<p>For the greeting node, the JSON body is:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $('Webhook').item.json.payload.from }}",
  "text": "Hi! I'm the bot. Send 'price' to see pricing, or anything else for help."
}
</code></pre>
<p>For the pricing node:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $('Webhook').item.json.payload.from }}",
  "text": "Our plans start at $49/month. Reply 'sales' to talk to a human."
}
</code></pre>
<p>For the fallback:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $('Webhook').item.json.payload.from }}",
  "text": "I didn't catch that. Try 'hi' or 'price'."
}
</code></pre>
<p>The <code>={{ ... }}</code> syntax is an n8n expression — at runtime it pulls values from earlier nodes.</p>
<p>Connect the Switch outputs to their matching HTTP Request nodes. Save the workflow. Click Activate in the top-right.</p>
<p>Send <code>hi</code> to your bot from any phone. It should reply within a second.</p>
<p>Congratulations — you have a WhatsApp bot running entirely on your own infrastructure.</p>
<h2 id="heading-part-8-a-second-example-proactive-booking-confirmations">Part 8: A Second Example — Proactive Booking Confirmations</h2>
<p>Auto-reply is useful. Proactive outbound is where the value really compounds. Here's a second workflow that sends a booking confirmation whenever a new row lands in a database.</p>
<p>Create a second workflow in n8n. Use one of these triggers:</p>
<ul>
<li><p><strong>Schedule Trigger</strong> — poll a database every minute for new rows</p>
</li>
<li><p><strong>Webhook Trigger</strong> — listen for a notification from your booking system</p>
</li>
<li><p><strong>Database Trigger</strong> (Postgres, MySQL, Supabase) — react to inserts in real time</p>
</li>
</ul>
<p>For this example, use a Schedule Trigger set to every minute, followed by a Postgres <strong>Execute Query</strong> node that reads pending confirmations:</p>
<pre><code class="language-sql">SELECT id, customer_phone, service_name, booking_time
FROM bookings
WHERE confirmation_sent = false
LIMIT 20;
</code></pre>
<p>After the Postgres node, add an HTTP Request node pointing to the same WAHA <code>sendText</code> endpoint you used earlier. The body:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $json.customer_phone }}@c.us",
  "text": "Hi! Your booking for {{ \(json.service_name }} on {{ \)json.booking_time }} is confirmed. Reply 'change' to reschedule."
}
</code></pre>
<p>Finally, add a second Postgres node that marks the booking as sent:</p>
<pre><code class="language-sql">UPDATE bookings
SET confirmation_sent = true, confirmation_sent_at = NOW()
WHERE id = {{ $json.id }};
</code></pre>
<p>Activate the workflow. Every minute, n8n pulls pending bookings, sends a WhatsApp confirmation, and marks them done.</p>
<p>This pattern generalizes. Replace the SQL with a call to Shopify for order confirmations, Stripe for receipt messages, or Calendly for appointment reminders. The WhatsApp layer stays the same — only the source of truth changes.</p>
<h2 id="heading-part-9-going-to-production">Part 9: Going to Production</h2>
<p>The setup above works, but it's not yet production-ready. Here's what to harden before you point real customers at it.</p>
<h3 id="heading-1-put-everything-behind-https">1. Put Everything Behind HTTPS</h3>
<p>Never expose n8n or WAHA directly on plain HTTP. Put a reverse proxy in front. Caddy is the easiest choice because it handles Let's Encrypt automatically.</p>
<p>A minimal <code>Caddyfile</code>:</p>
<pre><code class="language-plaintext">n8n.example.com {
    reverse_proxy n8n:5678
}

waha.example.com {
    reverse_proxy waha:3000
}
</code></pre>
<p>Run Caddy as another service in the same Docker Compose. TLS certificates are issued and renewed automatically.</p>
<h3 id="heading-2-rotate-the-api-keys">2. Rotate the API Keys</h3>
<p>Don't ship <code>super-secret-key-change-me</code> to production. Generate a real key:</p>
<pre><code class="language-bash">openssl rand -hex 32
</code></pre>
<p>Put it in a <code>.env</code> file, reference it as <code>${WHATSAPP_API_KEY}</code> in <code>docker-compose.yml</code>, and add <code>.env</code> to your <code>.gitignore</code>.</p>
<h3 id="heading-3-rate-limit-outbound-messages">3. Rate-limit Outbound Messages</h3>
<p>WhatsApp bans accounts that send too many messages too fast. A safe outbound rate for a fresh number is well under 20 messages per minute. For bursty replies, add an n8n Wait node between sends, or queue outgoing messages through a small custom function node that sleeps between requests.</p>
<h3 id="heading-4-scale-n8n-with-queue-mode">4. Scale n8n with Queue Mode</h3>
<p>By default, n8n runs everything in a single process. That's fine for low volume. For higher throughput, switch to Queue Mode:</p>
<ul>
<li><p>Add a Redis container.</p>
</li>
<li><p>Run one <code>n8n</code> main container (the web UI and webhook receiver).</p>
</li>
<li><p>Run one or more <code>n8n-worker</code> containers that pull jobs from the queue.</p>
</li>
</ul>
<p>Queue Mode is documented at <a href="https://docs.n8n.io/hosting/scaling/queue-mode/">docs.n8n.io/hosting/scaling/queue-mode/</a>. Setup adds two environment variables (<code>EXECUTIONS_MODE=queue</code>, <code>QUEUE_BULL_REDIS_HOST=redis</code>) and decouples incoming webhooks from workflow execution. The webhook responds in milliseconds while workers chew through the queue in the background.</p>
<h3 id="heading-5-monitor-the-session">5. Monitor the Session</h3>
<p>WhatsApp Web sessions drop. The phone loses connection, WhatsApp rotates security tokens, or your server reboots. Catch those drops early.</p>
<p>Subscribe to the <code>session.status</code> webhook event in WAHA. When status becomes <code>FAILED</code> or <code>STOPPED</code>, route it to an n8n workflow that posts to Slack, sends an email, or pages you. The faster you know, the faster you recover.</p>
<p>For overall uptime, point something like Uptime Kuma at <code>GET /api/sessions/default</code> on WAHA. If WAHA reports <code>WORKING</code>, you're fine. Anything else triggers an alert.</p>
<h3 id="heading-6-back-up-the-sessions-volume">6. Back Up the Sessions Volume</h3>
<p>The <code>waha-sessions</code> directory contains the logged-in state. If you lose it, you have to scan the QR code again — possibly from a phone that's no longer handy. Back it up nightly. A simple cron job with <code>tar</code> and <code>rclone</code> to S3-compatible storage is plenty.</p>
<h3 id="heading-7-add-a-live-agent-handoff">7. Add a Live-Agent Handoff</h3>
<p>Not every conversation should stay with the bot. When a user types <code>human</code> — or when your intent classifier can't answer confidently — hand off to a real agent.</p>
<p>Chatwoot is a solid open-source option: it has a dedicated WhatsApp channel, agent inbox, team assignment, and conversation history. The handoff is an n8n branch that stops processing bot replies and forwards the message stream to Chatwoot's API.</p>
<h2 id="heading-common-pitfalls">Common Pitfalls</h2>
<p>A few issues catch almost everyone on their first production deploy.</p>
<h3 id="heading-webhooks-timing-out">Webhooks Timing Out</h3>
<p>WAHA gives your webhook a few seconds to respond. If your n8n workflow is slow (calling an LLM, hitting a remote API), the webhook times out and WAHA retries, potentially causing duplicate replies.</p>
<p>Fix: make the webhook return <code>200</code> immediately and offload the slow work. In n8n, set the Webhook node's Response Mode to <em>Using Respond to Webhook Node</em>, add a Respond to Webhook node as the first step with a <code>200</code> and empty body, then do the heavy lifting after that.</p>
<h3 id="heading-duplicate-messages">Duplicate Messages</h3>
<p>WAHA delivers the same <code>message</code> event more than once in edge cases (phone comes back online, session reconnects). Store the <code>payload.id</code> somewhere — Redis, a database, or n8n's static data store — and drop any ID you've already processed.</p>
<h3 id="heading-messages-arriving-out-of-order">Messages Arriving Out of Order</h3>
<p>The webhook is async, and n8n may parallelize executions. If ordering matters — for example, in a multi-step conversation — key a queue by the sender's <code>chatId</code> and process each sender serially.</p>
<h3 id="heading-sessions-disconnecting-after-a-phone-restart">Sessions Disconnecting After a Phone Restart</h3>
<p>Normal WhatsApp Web behavior. WAHA auto-reconnects, but occasionally the linked-devices list needs a manual refresh. If a session refuses to come back, stop the WAHA container, delete that session's folder under <code>waha-sessions/</code>, start the container again, and rescan the QR.</p>
<h3 id="heading-your-number-gets-banned">Your Number Gets Banned</h3>
<p>The single biggest cause is rate: a new number blasting hundreds of messages an hour gets flagged fast. Warm up a number slowly — send a normal, human-like volume for the first week. Don't send to strangers unsolicited. Prefer inbound-driven replies over outbound pushes wherever you can.</p>
<h3 id="heading-the-wrong-chat-id-format">The Wrong Chat ID Format</h3>
<p>WhatsApp individual chats use <code>&lt;number&gt;@c.us</code> and groups use <code>&lt;groupId&gt;@g.us</code>. Don't include the <code>+</code> or spaces in the number. If WAHA returns a 404 when sending, the chat ID is almost always the problem.</p>
<h2 id="heading-where-to-go-next">Where to Go Next</h2>
<p>You now have the foundation. The same two-service stack supports almost any bot you can imagine — you're only limited by what you can build in an n8n workflow.</p>
<p>Some natural next steps:</p>
<ul>
<li><p><strong>Plug in AI replies:</strong> Add an OpenAI or Anthropic node after the Webhook, pass the user's message through it with a short system prompt, and send the response back through WAHA. Cap conversation length to prevent runaway token usage.</p>
</li>
<li><p><strong>Integrate a CRM:</strong> Look up the caller's <code>chatId</code> in HubSpot, Pipedrive, or your own database before deciding how to reply. Segment responses by customer tier.</p>
</li>
<li><p><strong>Send proactive notifications:</strong> Appointment reminders, shipping updates, payment receipts, abandoned-cart nudges. Keep the content transactional and expected — unsolicited marketing blasts are the fastest way to a ban.</p>
</li>
<li><p><strong>Log every conversation:</strong> Add a Postgres or Supabase node after the Webhook to persist messages for analytics and customer history. Your future self (and your support team) will thank you.</p>
</li>
<li><p><strong>Add media handling:</strong> WAHA exposes <code>sendImage</code>, <code>sendFile</code>, and <code>sendVoice</code> endpoints. Teach the bot to accept photos for support tickets, or send invoices as PDFs directly inside the chat.</p>
</li>
</ul>
<p>The WhatsApp layer stays the same. Everything interesting happens upstream in the workflow.</p>
<p><em>If you want to see production examples of n8n and WAHA running at scale — or you need a similar automation built for your business — I'm the founder of Achiya Automation, where we ship WhatsApp, n8n, and Chatwoot integrations. You can find more at</em> <a href="https://achiya-automation.com"><em>achiya-automation.com</em></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Reclaim Your Time – Master Automation with Zapier ]]>
                </title>
                <description>
                    <![CDATA[ Do you ever spend a lot of time doing small repetitive tasks like copying data from an email into a spreadsheet or manually moving files between folders. We just posted a new course on the freeCodeCam ]]>
                </description>
                <link>https://www.freecodecamp.org/news/reclaim-your-time-master-automation-with-zapier/</link>
                <guid isPermaLink="false">69e79069e4367278145b1128</guid>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Tue, 21 Apr 2026 14:57:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/80b2d07a-dc5c-4b73-a50c-5c4b5c462a74.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Do you ever spend a lot of time doing small repetitive tasks like copying data from an email into a spreadsheet or manually moving files between folders.</p>
<p>We just posted a new course on the <a href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel, led by instructor and developer Estafania, that will help you leverage the power of automation to help with all your tasks.</p>
<p>Zapier is a no-code platform that allows you to connect and share information between the applications you use every day. The core philosophy is simple: "If this happens, then do that".</p>
<ul>
<li><p><strong>The Trigger:</strong> This is the "If this happens" part. It's a specific event in one app (like receiving a new lead in a form).</p>
</li>
<li><p><strong>The Action:</strong> This is the "do that" part. This is the task Zapier performs automatically in another app (like sending a Slack notification or adding a row to a Google Sheet).</p>
</li>
</ul>
<p>This four-hour course takes you from a complete beginner to an advanced user. You will start by setting up a free account and learning the basic building blocks of a "Zap". As you progress, you will dive into modern, AI-enhanced features.</p>
<p>And for people looking to bridge the gap between AI and development, the course concludes with a deep dive into Model Context Protocol (MCP). You will learn how to set up an MCP server to share information from your apps with AI clients like Visual Studio Code and the Gemini CLI. This allows you to interact with your Google Calendar or GitHub repositories directly through an AI interface.</p>
<p>Watch the full course on <a href="https://youtu.be/-leIp449qXA">the freeCodeCamp.org YouTube channel</a> (4-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/-leIp449qXA" 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 a Local SEO Audit Agent with Browser Use and Claude API ]]>
                </title>
                <description>
                    <![CDATA[ Every digital marketing agency has someone whose job involves opening a spreadsheet, visiting each client URL, checking the title tag, meta description, and H1, noting broken links, and pasting everyt ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-local-seo-audit-agent-with-browser-use-and-claude-api/</link>
                <guid isPermaLink="false">69cb09249fffa747409f133f</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python 3 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude.ai ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daniel Nwaneri ]]>
                </dc:creator>
                <pubDate>Mon, 30 Mar 2026 23:37:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/98f8eb73-bfe2-4990-b41a-1997a35134f2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every digital marketing agency has someone whose job involves opening a spreadsheet, visiting each client URL, checking the title tag, meta description, and H1, noting broken links, and pasting everything into a report. Then doing it again next week.</p>
<p>That work is deterministic. An agent can do it.</p>
<p>In this tutorial, you'll build a local SEO audit agent from scratch using Python, Browser Use, and the Claude API. The agent visits real pages in a visible browser window, extracts SEO signals using Claude, checks for broken links asynchronously, handles edge cases with a human-in-the-loop pause, and writes a structured report — all resumable if interrupted.</p>
<p>By the end, you'll have a working agent you can run against any list of URLs. It costs less than $0.01 per URL to run.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>A seven-module Python agent that:</p>
<ul>
<li><p>Reads a URL list from a CSV file</p>
</li>
<li><p>Visits each URL in a real Chromium browser (not a headless scraper)</p>
</li>
<li><p>Extracts title, meta description, H1s, and canonical tag via Claude API</p>
</li>
<li><p>Checks for broken links asynchronously using httpx</p>
</li>
<li><p>Detects edge cases (404s, login walls, redirects) and pauses for human input</p>
</li>
<li><p>Writes results to <code>report.json</code> incrementally — safe to interrupt and resume</p>
</li>
<li><p>Generates a plain-English <code>report-summary.txt</code> on completion</p>
</li>
</ul>
<p>The full code is on GitHub at <a href="https://github.com/dannwaneri/seo-agent">dannwaneri/seo-agent</a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Python 3.11 or higher</p>
</li>
<li><p>An Anthropic API key (get one at console.anthropic.com)</p>
</li>
<li><p>Windows, macOS, or Linux</p>
</li>
<li><p>Basic familiarity with Python and the command line</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-why-browser-use-instead-of-a-scraper">Why Browser Use Instead of a Scraper</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
<li><p><a href="#heading-module-1-state-management">Module 1: State Management</a></p>
</li>
<li><p><a href="#heading-module-2-browser-integration">Module 2: Browser Integration</a></p>
</li>
<li><p><a href="#heading-module-3-claude-extraction-layer">Module 3: Claude Extraction Layer</a></p>
</li>
<li><p><a href="#heading-module-4-broken-link-checker">Module 4: Broken Link Checker</a></p>
</li>
<li><p><a href="#heading-module-5-human-in-the-loop">Module 5: Human-in-the-Loop</a></p>
</li>
<li><p><a href="#heading-module-6-report-writer">Module 6: Report Writer</a></p>
</li>
<li><p><a href="#heading-module-7-the-main-loop">Module 7: The Main Loop</a></p>
</li>
<li><p><a href="#heading-running-the-agent">Running the Agent</a></p>
</li>
<li><p><a href="#heading-scheduling-for-agency-use">Scheduling for Agency Use</a></p>
</li>
<li><p><a href="#heading-what-the-results-look-like">What the Results Look Like</a></p>
</li>
</ol>
<h2 id="heading-why-browser-use-instead-of-a-scraper">Why Browser Use Instead of a Scraper</h2>
<p>The standard approach to SEO auditing is to fetch page HTML with <code>requests</code> and parse it with BeautifulSoup. That works on static pages. It breaks on JavaScript-rendered content, misses dynamically injected meta tags, and fails entirely on authenticated pages.</p>
<p>Browser Use (84,000+ GitHub stars, MIT license) takes a different approach. It controls a real Chromium browser, reads the DOM after JavaScript executes, and exposes the page through Playwright's accessibility tree. The agent sees what a human would see.</p>
<p>The practical difference: a requests-based scraper might miss a meta description injected by a React component. Browser Use won't.</p>
<p>The other difference worth naming: Browser Use reads pages semantically. A Playwright script breaks when a button's CSS class changes from <code>btn-primary</code> to <code>button-main</code>. Browser Use identifies it's still a "Submit" button and acts accordingly. The extraction logic lives in the Claude prompt, not in brittle CSS selectors.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<pre><code class="language-plaintext">seo-agent/
├── index.py          # Main audit loop
├── browser.py        # Browser Use / Playwright page driver
├── extractor.py      # Claude API extraction layer
├── linkchecker.py    # Async broken link checker
├── hitl.py           # Human-in-the-loop pause logic
├── reporter.py       # Report writer
├── state.py          # State persistence (resume on interrupt)
├── input.csv         # Your URL list
├── requirements.txt
├── .env.example
└── .gitignore
</code></pre>
<h2 id="heading-setup">Setup</h2>
<p>Create a project folder and install dependencies:</p>
<pre><code class="language-bash">mkdir seo-agent &amp;&amp; cd seo-agent
pip install browser-use anthropic playwright httpx
playwright install chromium
</code></pre>
<p>Create <code>input.csv</code> with your URLs:</p>
<pre><code class="language-plaintext">url
https://example.com
https://example.com/about
https://example.com/contact
</code></pre>
<p>Create <code>.env.example</code>:</p>
<pre><code class="language-plaintext">ANTHROPIC_API_KEY=your-key-here
</code></pre>
<p>Set your API key as an environment variable before running:</p>
<pre><code class="language-bash"># macOS/Linux
export ANTHROPIC_API_KEY="sk-ant-..."

# Windows PowerShell
$env:ANTHROPIC_API_KEY = "sk-ant-..."
</code></pre>
<p>Create <code>.gitignore</code>:</p>
<pre><code class="language-plaintext">state.json
report.json
report-summary.txt
.env
__pycache__/
*.pyc
</code></pre>
<h2 id="heading-module-1-state-management">Module 1: State Management</h2>
<p>The agent needs to track which URLs it has already audited. If the run is interrupted — power cut, keyboard interrupt, network error — it should resume from where it stopped, not start over.</p>
<p><code>state.py</code> handles this with a flat JSON file:</p>
<pre><code class="language-python">import json
import os

STATE_FILE = os.path.join(os.path.dirname(__file__), "state.json")

_DEFAULT_STATE = {"audited": [], "pending": [], "needs_human": []}


def load_state() -&gt; dict:
    if not os.path.exists(STATE_FILE):
        save_state(_DEFAULT_STATE.copy())
    with open(STATE_FILE, encoding="utf-8") as f:
        return json.load(f)


def save_state(state: dict) -&gt; None:
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        json.dump(state, f, indent=2)


def is_audited(url: str) -&gt; bool:
    return url in load_state()["audited"]


def mark_audited(url: str) -&gt; None:
    state = load_state()
    if url not in state["audited"]:
        state["audited"].append(url)
    save_state(state)


def add_to_needs_human(url: str) -&gt; None:
    state = load_state()
    if url not in state["needs_human"]:
        state["needs_human"].append(url)
    save_state(state)
</code></pre>
<p>The design is intentional: <code>mark_audited()</code> is called immediately after a URL is processed and written to the report. If the agent crashes mid-run, it loses at most one URL's work.</p>
<h2 id="heading-module-2-browser-integration">Module 2: Browser Integration</h2>
<p><code>browser.py</code> does the actual page navigation. It uses Playwright directly (which Browser Use installs as a dependency) to open a visible Chromium window, navigate to the URL, capture HTTP status and redirect information, and extract the raw SEO signals from the DOM.</p>
<p>The key design decisions:</p>
<p><strong>Visible browser, not headless.</strong> Set <code>headless=False</code> so you can watch the agent work. This matters for the demo and for debugging.</p>
<p><strong>Status capture via response listener.</strong> Playwright raises an exception on 4xx/5xx responses, but the <code>on("response", ...)</code> handler fires before the exception. We capture status there.</p>
<p><strong>2-second delay between visits.</strong> Prevents triggering rate limiting or bot detection on agency client sites.</p>
<p>Here is the core navigation function:</p>
<pre><code class="language-python">import asyncio
import sys
import time
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

TIMEOUT = 20_000  # 20 seconds


def fetch_page(url: str) -&gt; dict:
    result = {
        "final_url": url,
        "status_code": None,
        "title": None,
        "meta_description": None,
        "h1s": [],
        "canonical": None,
        "raw_links": [],
    }

    first_status = {"code": None}

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()

        def on_response(response):
            if first_status["code"] is None:
                first_status["code"] = response.status

        page.on("response", on_response)

        try:
            page.goto(url, wait_until="domcontentloaded", timeout=TIMEOUT)
            result["status_code"] = first_status["code"] or 200
            result["final_url"] = page.url

            # Extract SEO signals from DOM
            result["title"] = page.title() or None
            result["meta_description"] = page.evaluate(
                "() =&gt; { const m = document.querySelector('meta[name=\"description\"]'); "
                "return m ? m.getAttribute('content') : null; }"
            )
            result["h1s"] = page.evaluate(
                "() =&gt; Array.from(document.querySelectorAll('h1')).map(h =&gt; h.innerText.trim())"
            )
            result["canonical"] = page.evaluate(
                "() =&gt; { const c = document.querySelector('link[rel=\"canonical\"]'); "
                "return c ? c.getAttribute('href') : null; }"
            )
            result["raw_links"] = page.evaluate(
                "() =&gt; Array.from(document.querySelectorAll('a[href]'))"
                ".map(a =&gt; a.href).filter(Boolean).slice(0, 100)"
            )

        except PlaywrightTimeout:
            result["status_code"] = first_status["code"] or 408
        except Exception as exc:
            print(f"[browser] Error: {exc}", file=sys.stderr)
            result["status_code"] = first_status["code"]
        finally:
            browser.close()

    time.sleep(2)
    return result
</code></pre>
<p>A few things worth noting:</p>
<p>The <code>raw_links</code> cap at 100 is deliberate. DEV.to profile pages have hundreds of links — you don't need all of them for broken link detection.</p>
<p>The <code>wait_until="domcontentloaded"</code> setting is faster than <code>networkidle</code> and sufficient for meta tag extraction. JavaScript-rendered content needs the DOM to be ready, not all network requests to complete.</p>
<h2 id="heading-module-3-claude-extraction-layer">Module 3: Claude Extraction Layer</h2>
<p><code>extractor.py</code> takes the raw page snapshot from <code>browser.py</code> and calls Claude to produce a structured SEO audit result.</p>
<p>This is where most tutorials go wrong. They either write complex parsing logic in Python (fragile) or ask Claude for a free-form response and try to parse prose (unreliable). The right approach: give Claude a strict JSON schema and tell it to return nothing else.</p>
<p><strong>The prompt engineering that makes this reliable:</strong></p>
<pre><code class="language-python">import json
import os
import sys
from datetime import datetime, timezone
import anthropic

MODEL = "claude-sonnet-4-20250514"
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))


def _strip_fences(text: str) -&gt; str:
    """Remove accidental markdown code fences from Claude's response."""
    text = text.strip()
    if text.startswith("```"):
        lines = text.splitlines()
        # Drop opening fence
        lines = lines[1:] if lines[0].startswith("```") else lines
        # Drop closing fence
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()
    return text


def extract(snapshot: dict) -&gt; dict:
    if not os.environ.get("ANTHROPIC_API_KEY"):
        raise OSError("ANTHROPIC_API_KEY is not set.")

    prompt = f"""You are an SEO auditor. Analyze this page snapshot and return ONLY a JSON object.
No prose. No explanation. No markdown fences. Raw JSON only.

Page data:
- URL: {snapshot.get('final_url')}
- Status code: {snapshot.get('status_code')}
- Title: {snapshot.get('title')}
- Meta description: {snapshot.get('meta_description')}
- H1 tags: {snapshot.get('h1s')}
- Canonical: {snapshot.get('canonical')}

Return this exact schema:
{{
  "url": "string",
  "final_url": "string",
  "status_code": number,
  "title": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
  "description": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
  "h1": {{"count": number, "value": "string or null", "status": "PASS or FAIL"}},
  "canonical": {{"value": "string or null", "status": "PASS or FAIL"}},
  "flags": ["array of strings describing specific issues"],
  "human_review": false,
  "audited_at": "ISO timestamp"
}}

PASS/FAIL rules:
- title: FAIL if null or length &gt; 60 characters
- description: FAIL if null or length &gt; 160 characters  
- h1: FAIL if count is 0 (missing) or count &gt; 1 (multiple)
- canonical: FAIL if null
- flags: list every failing field with a clear description
- audited_at: use current UTC time in ISO 8601 format"""

    response = client.messages.create(
        model=MODEL,
        max_tokens=1000,
        messages=[{"role": "user", "content": prompt}],
    )

    raw = response.content[0].text
    clean = _strip_fences(raw)

    try:
        return json.loads(clean)
    except json.JSONDecodeError as exc:
        print(f"[extractor] JSON parse error: {exc}", file=sys.stderr)
        return _error_result(snapshot, str(exc))


def _error_result(snapshot: dict, reason: str) -&gt; dict:
    return {
        "url": snapshot.get("final_url", ""),
        "final_url": snapshot.get("final_url", ""),
        "status_code": snapshot.get("status_code"),
        "title": {"value": None, "length": 0, "status": "ERROR"},
        "description": {"value": None, "length": 0, "status": "ERROR"},
        "h1": {"count": 0, "value": None, "status": "ERROR"},
        "canonical": {"value": None, "status": "ERROR"},
        "flags": [f"Extraction error: {reason}"],
        "human_review": True,
        "audited_at": datetime.now(timezone.utc).isoformat(),
    }
</code></pre>
<p>Two things make this reliable in production:</p>
<p>First, <code>_strip_fences()</code> handles the case where Claude wraps its response in <code>```json</code> fences despite being told not to. This happens occasionally with Sonnet and consistently breaks <code>json.loads()</code> if you don't handle it.</p>
<p>Second, the <code>_error_result()</code> fallback means the agent never crashes on a bad Claude response — it logs the error and marks the URL for human review, then continues to the next URL.</p>
<p><strong>Cost:</strong> Claude Sonnet 4 is priced at \(3 per million input tokens and \)15 per million output tokens. A typical page snapshot is around 500 input tokens; the structured JSON response is around 300 output tokens. That works out to roughly \(0.006 per URL — about \)0.12 for a 20-URL audit.</p>
<h2 id="heading-module-4-broken-link-checker">Module 4: Broken Link Checker</h2>
<p><code>linkchecker.py</code> takes the <code>raw_links</code> list from the browser snapshot and checks same-domain links for broken status using async HEAD requests.</p>
<p>The design choices:</p>
<ul>
<li><p><strong>Same-domain only.</strong> Checking every external link on a page would take minutes and isn't what agency clients need. Filter to links on the same domain as the page being audited.</p>
</li>
<li><p><strong>HEAD requests, not GET.</strong> Faster, lower bandwidth, sufficient for status code detection.</p>
</li>
<li><p><strong>Cap at 50 links.</strong> Pages like DEV.to article listings have hundreds of internal links. Checking all of them would dominate the runtime.</p>
</li>
<li><p><strong>Concurrent requests via asyncio.</strong> All links are checked in parallel, not sequentially.</p>
</li>
</ul>
<pre><code class="language-python">import asyncio
import logging
from urllib.parse import urlparse
import httpx

CAP = 50
TIMEOUT = 5.0
logger = logging.getLogger(__name__)


def _same_domain(link: str, final_url: str) -&gt; bool:
    if not link:
        return False
    lower = link.strip().lower()
    if lower.startswith(("#", "mailto:", "javascript:", "tel:", "data:")):
        return False
    try:
        page_host = urlparse(final_url).netloc.lower()
        parsed = urlparse(link)
        return parsed.scheme in ("http", "https") and parsed.netloc.lower() == page_host
    except Exception:
        return False


async def _check_link(client: httpx.AsyncClient, url: str) -&gt; tuple[str, bool]:
    try:
        resp = await client.head(url, follow_redirects=True, timeout=TIMEOUT)
        return url, resp.status_code != 200
    except Exception:
        return url, True  # Timeout or connection error = broken


async def _run_checks(links: list[str]) -&gt; list[str]:
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(*[_check_link(client, url) for url in links])
    return [url for url, broken in results if broken]


def check_links(raw_links: list[str], final_url: str) -&gt; dict:
    same_domain = [l for l in raw_links if _same_domain(l, final_url)]

    capped = len(same_domain) &gt; CAP
    if capped:
        logger.warning("Page has %d same-domain links — capping at %d.", len(same_domain), CAP)
        same_domain = same_domain[:CAP]

    broken = asyncio.run(_run_checks(same_domain))

    return {
        "broken": broken,
        "count": len(broken),
        "status": "FAIL" if broken else "PASS",
        "capped": capped,
    }
</code></pre>
<h2 id="heading-module-5-human-in-the-loop">Module 5: Human-in-the-Loop</h2>
<p>This is the part most automation tutorials skip. What happens when the agent hits a login wall? A page that returns 403? A URL that redirects to a "Subscribe to continue reading" page?</p>
<p>Most scripts either crash or silently skip. Neither is acceptable in an agency context.</p>
<p><code>hitl.py</code> handles this with two functions: one that detects whether a pause is needed, and one that handles the pause itself.</p>
<pre><code class="language-python">from state import add_to_needs_human

LOGIN_KEYWORDS = {"login", "sign in", "sign-in", "access denied", "log in", "unauthorized"}
REDIRECT_CODES = {301, 302, 307, 308}


def should_pause(snapshot: dict) -&gt; bool:
    code = snapshot.get("status_code")

    # Navigation failed entirely
    if code is None:
        return True

    # Non-200, non-redirect
    if code != 200 and code not in REDIRECT_CODES:
        return True

    # Login wall detection
    title = (snapshot.get("title") or "").lower()
    h1s = [h.lower() for h in (snapshot.get("h1s") or [])]

    if any(kw in title for kw in LOGIN_KEYWORDS):
        return True
    if any(kw in h1 for kw in LOGIN_KEYWORDS for h1 in h1s):
        return True

    return False


def pause_reason(snapshot: dict) -&gt; str:
    code = snapshot.get("status_code")
    if code is None:
        return "Navigation failed (None status)"
    if code != 200 and code not in REDIRECT_CODES:
        return f"Unexpected status code: {code}"
    return "Possible login wall detected"


def pause_and_prompt(url: str, reason: str) -&gt; str:
    print(f"\n⚠️  HUMAN REVIEW NEEDED")
    print(f"   URL:    {url}")
    print(f"   Reason: {reason}")
    print(f"   Options: [s] skip  [r] retry  [q] quit\n")

    while True:
        choice = input("Your choice: ").strip().lower()
        if choice in ("s", "r", "q"):
            return {"s": "skip", "r": "retry", "q": "quit"}[choice]
        print("   Enter s, r, or q.")
</code></pre>
<p>The <code>should_pause()</code> function catches four cases: navigation failure, unexpected HTTP status, login keywords in the title, and login keywords in H1 tags. The login keyword check is what catches "Please sign in to continue" pages that return 200 but are effectively inaccessible.</p>
<p>In <code>--auto</code> mode (for scheduled runs), the main loop skips the <code>pause_and_prompt()</code> call and automatically handles these cases by logging the URL to <code>needs_human[]</code> in state and continuing.</p>
<h2 id="heading-module-6-report-writer">Module 6: Report Writer</h2>
<p><code>reporter.py</code> writes results incrementally. This is important: results are written after each URL is audited, not batched at the end. If the run is interrupted, you don't lose completed work.</p>
<pre><code class="language-python">import json
import os
from datetime import datetime, timezone

REPORT_JSON = os.path.join(os.path.dirname(__file__), "report.json")
REPORT_TXT = os.path.join(os.path.dirname(__file__), "report-summary.txt")


def _load_report() -&gt; list:
    if not os.path.exists(REPORT_JSON):
        return []
    with open(REPORT_JSON, encoding="utf-8") as f:
        return json.load(f)


def write_result(result: dict) -&gt; None:
    """Append or update a result in report.json."""
    entries = _load_report()
    url = result.get("url", "")

    # Update existing entry if URL already present (handles retries)
    for i, entry in enumerate(entries):
        if entry.get("url") == url:
            entries[i] = result
            break
    else:
        entries.append(result)

    with open(REPORT_JSON, "w", encoding="utf-8") as f:
        json.dump(entries, f, indent=2, ensure_ascii=False)


def _is_overall_pass(result: dict) -&gt; bool:
    fields = ["title", "description", "h1", "canonical"]
    for field in fields:
        if result.get(field, {}).get("status") not in ("PASS",):
            return False
    if result.get("broken_links", {}).get("status") == "FAIL":
        return False
    return True


def write_summary() -&gt; None:
    entries = _load_report()
    passed = sum(1 for e in entries if _is_overall_pass(e))

    lines = []
    for entry in entries:
        overall = "PASS" if _is_overall_pass(entry) else "FAIL"
        failed_fields = [
            f for f in ["title", "description", "h1", "canonical", "broken_links"]
            if entry.get(f, {}).get("status") == "FAIL"
        ]
        suffix = f" [{', '.join(failed_fields)}]" if failed_fields else ""
        lines.append(f"{entry.get('url', 'unknown'):&lt;60} | {overall}{suffix}")

    lines.append("")
    lines.append(f"{passed}/{len(entries)} URLs passed")

    with open(REPORT_TXT, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))
</code></pre>
<p>The deduplication in <code>write_result()</code> handles retries cleanly. If a URL is retried after a human reviews a login wall and authenticates, the new result replaces the old one rather than creating a duplicate entry.</p>
<h2 id="heading-module-7-the-main-loop">Module 7: The Main Loop</h2>
<p><code>index.py</code> wires everything together. It reads the URL list, loads state, skips already-audited URLs, and runs the audit loop.</p>
<pre><code class="language-python">import csv
import os
import sys
import time
import argparse

from state import load_state, is_audited, mark_audited, add_to_needs_human
from browser import fetch_page
from extractor import extract
from linkchecker import check_links
from hitl import should_pause, pause_reason, pause_and_prompt
from reporter import write_result, write_summary

INPUT_CSV = os.path.join(os.path.dirname(__file__), "input.csv")


def read_urls(path: str) -&gt; list[str]:
    with open(path, newline="", encoding="utf-8") as f:
        return [row["url"].strip() for row in csv.DictReader(f) if row.get("url", "").strip()]


def run(auto: bool = False):
    if not os.environ.get("ANTHROPIC_API_KEY"):
        print("Error: ANTHROPIC_API_KEY environment variable is not set.")
        sys.exit(1)

    urls = read_urls(INPUT_CSV)
    pending = [u for u in urls if not is_audited(u)]

    print(f"Starting audit: {len(pending)} pending, {len(urls) - len(pending)} already done.\n")

    total = len(urls)

    try:
        for i, url in enumerate(pending, start=1):
            position = urls.index(url) + 1
            print(f"[{position}/{total}] {url}", end=" -&gt; ", flush=True)

            # Browser navigation
            snapshot = fetch_page(url)

            # Human-in-the-loop check
            if should_pause(snapshot):
                reason = pause_reason(snapshot)

                if auto:
                    print(f"AUTO-SKIPPED ({reason})")
                    add_to_needs_human(url)
                    mark_audited(url)
                    continue

                action = pause_and_prompt(url, reason)
                if action == "quit":
                    print("Exiting.")
                    break
                elif action == "skip":
                    add_to_needs_human(url)
                    mark_audited(url)
                    continue
                # "retry" falls through to re-fetch below
                snapshot = fetch_page(url)

            # Claude extraction
            result = extract(snapshot)

            # Broken link check
            links = check_links(snapshot.get("raw_links", []), snapshot.get("final_url", url))
            result["broken_links"] = links

            # Write result immediately
            write_result(result)
            mark_audited(url)

            overall = "PASS" if all(
                result.get(f, {}).get("status") == "PASS"
                for f in ["title", "description", "h1", "canonical"]
            ) and links["status"] == "PASS" else "FAIL"

            print(overall)

    except KeyboardInterrupt:
        print("\n\nInterrupted. Progress saved. Re-run to continue.")
        return

    write_summary()
    passed = sum(
        1 for e in [r for r in []]
        if all(e.get(f, {}).get("status") == "PASS" for f in ["title", "description", "h1", "canonical"])
    )
    print(f"\nAudit complete. Report saved to report.json and report-summary.txt")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--auto", action="store_true", help="Auto-skip URLs requiring human review")
    args = parser.parse_args()
    run(auto=args.auto)
</code></pre>
<p>The <code>KeyboardInterrupt</code> handler is the resume mechanism. When you press Ctrl+C, the handler prints a message and exits cleanly. Because <code>mark_audited()</code> is called after <code>write_result()</code> for each URL, the next run skips everything already processed.</p>
<h2 id="heading-running-the-agent">Running the Agent</h2>
<p>Interactive mode (pauses on edge cases):</p>
<pre><code class="language-bash">python index.py
</code></pre>
<p>Auto mode (skips edge cases, adds to <code>needs_human[]</code>):</p>
<pre><code class="language-bash">python index.py --auto
</code></pre>
<p>When it runs, you'll see the browser window open for each URL and the terminal print progress:</p>
<pre><code class="language-plaintext">Starting audit: 7 pending, 0 already done.

[1/7] https://example.com -&gt; PASS
[2/7] https://example.com/about -&gt; FAIL
[3/7] https://example.com/contact -&gt; AUTO-SKIPPED (Unexpected status code: 404)
...
Audit complete. Report saved to report.json and report-summary.txt
</code></pre>
<p>To resume after an interruption:</p>
<pre><code class="language-bash">python index.py --auto
# Starting audit: 4 pending, 3 already done.
</code></pre>
<h2 id="heading-scheduling-for-agency-use">Scheduling for Agency Use</h2>
<p>For recurring weekly audits, create a batch file and schedule it with Windows Task Scheduler.</p>
<p>Create <code>run-audit.bat</code>:</p>
<pre><code class="language-batch">@echo off
set ANTHROPIC_API_KEY=your-key-here
cd /d C:\Users\yourname\Desktop\seo-agent
python index.py --auto
</code></pre>
<p>In Windows Task Scheduler:</p>
<ol>
<li><p>Create a new Basic Task</p>
</li>
<li><p>Set the trigger to Weekly, Monday at 7:00 AM</p>
</li>
<li><p>Set the action to "Start a program"</p>
</li>
<li><p>Browse to your <code>run-audit.bat</code> file</p>
</li>
</ol>
<p>Check <code>report-summary.txt</code> on Monday morning. URLs in <code>needs_human[]</code> in <code>state.json</code> need manual review — login walls, paywalls, or pages that returned unexpected status codes.</p>
<p>For macOS/Linux, use cron:</p>
<pre><code class="language-bash"># Run every Monday at 7am
0 7 * * 1 cd /path/to/seo-agent &amp;&amp; ANTHROPIC_API_KEY=your-key python index.py --auto
</code></pre>
<h2 id="heading-what-the-results-look-like">What the Results Look Like</h2>
<p>I ran this agent against seven of my own published pages across Hashnode, freeCodeCamp, and DEV.to. Every single one failed.</p>
<pre><code class="language-plaintext">https://hashnode.com/@dannwaneri                    | FAIL [h1]
https://freecodecamp.org/news/claude-code-skill     | FAIL [description]
https://freecodecamp.org/news/stop-letting-ai-guess | FAIL [description]
https://freecodecamp.org/news/rag-system-handbook   | FAIL [title, description]
https://freecodecamp.org/news/author/dannwaneri     | FAIL [description]
https://dev.to/dannwaneri/gatekeeping-panic         | FAIL [title]
https://dev.to/dannwaneri/production-rag-system     | FAIL [title]

0/7 URLs passed
</code></pre>
<p>The freeCodeCamp description issues are partly platform-level — freeCodeCamp's template sometimes truncates or omits meta descriptions for article listing pages. The DEV.to title issues are mine. Article titles that work as headlines often exceed 60 characters in the <code>&lt;title&gt;</code> tag.</p>
<p>A note on the 60-character title rule: this is a display threshold, not a ranking penalty. Google indexes titles of any length. The 60-character guideline reflects approximately how many characters fit in a desktop SERP result before truncation. Titles over 60 characters often still rank — they just get cut off in search results, which can hurt click-through rate. The agent flags display risk, not a ranking violation.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>The agent as built handles the core SEO audit workflow. Obvious extensions:</p>
<ul>
<li><p><strong>Performance metrics</strong> — add a Lighthouse or PageSpeed Insights API call per URL</p>
</li>
<li><p><strong>Structured data validation</strong> — check for JSON-LD schema markup and validate it</p>
</li>
<li><p><strong>Email delivery</strong> — send <code>report-summary.txt</code> via SMTP after the run completes</p>
</li>
<li><p><strong>Multi-client support</strong> — separate <code>input.csv</code> files per client, separate report directories</p>
</li>
</ul>
<p>The full code including all seven modules is at <a href="https://github.com/dannwaneri/seo-agent">dannwaneri/seo-agent</a>. Clone it, add your URLs, and run it.</p>
<p><em>If you found this useful, I write about practical AI agent setups for developers and agencies at</em> <a href="https://dev.to/dannwaneri"><em>DEV.to/@dannwaneri</em></a><em>. The DEV.to companion piece covers the design decisions behind the agent — why HITL matters, why Browser Use over scrapers, and what the audit results mean for your own published content.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Find Any File on Windows Like a Linux User (using Windows Powershell) ]]>
                </title>
                <description>
                    <![CDATA[ Sometimes you might struggle to find a file or program when you have no idea where it could be saved or installed. And the Windows user interface may not always give you the results you want. If that' ]]>
                </description>
                <link>https://www.freecodecamp.org/news/find-any-file-on-windows-like-a-linux-user/</link>
                <guid isPermaLink="false">69c44ce410e664c5daef3e59</guid>
                
                    <category>
                        <![CDATA[ Windows ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Powershell ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Scripting ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Piotr &quot;NotBlackMagic&quot; Opoka ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 16:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/527c9267-0583-49c4-9e90-89abcf186b9d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Sometimes you might struggle to find a file or program when you have no idea where it could be saved or installed. And the Windows user interface may not always give you the results you want. If that's the case for you, you're in the right place.</p>
<p><code>Get-ChildItem</code> (also known as <code>gci</code>, <code>ls</code>, <code>dir</code> ) is a very powerful command. And one of its most iconic uses is to find/search for a file. It's more precise and more reliable than Windows Explorer. It even has better filtering options that show the results that are more relevant to you.</p>
<p>In this tutorial, you'll learn how to use <code>gci</code> and how to combine it with other commands so that it becomes an even more powerful tool. Remember to enable copy-pasting in Windows PowerShell, so it's easier for you to follow along. You can see how to enable it <a href="https://notblackmagic.hashnode.dev/enable-copy-pasting-in-windows-powershell-cli-in-3-steps">here</a>.</p>
<h3 id="heading-what-well-cover">What we'll cover:</h3>
<ol>
<li><p><a href="#heading-1-basic-explanation-of-the-get-childitem-command">Basic explanation of the Get-ChildItem command</a></p>
<ul>
<li><a href="#heading-most-used-examples-of-searching-by-gci-command">Most used examples of searching by gci command</a></li>
</ul>
</li>
<li><p><a href="#heading-2-setup-for-other-more-complex-examples">Setup for other more complex examples</a></p>
</li>
<li><p><a href="#heading-3-when-is-the-path-option-not-needed">When is the -Path option not needed?</a></p>
</li>
<li><p><a href="#heading-4-advanced-searching-combining-getchildren-with-the-whereobject-command">Advanced Searching – Combining Get-ChildItem with the Where-Object Command</a></p>
<ul>
<li><p><a href="#heading-41-how-to-search-through-only-a-particular-directory">4.1. How to search through only a particular directory</a></p>
</li>
<li><p><a href="#heading-42-how-to-search-while-excluding-a-particular-directory">4.2. How to search while excluding a particular directory</a></p>
</li>
<li><p><a href="#heading-43-searching-only-1-directory-from-many-with-exactly-the-same-name">4.3 Searching only 1 directory from many with exactly the same name</a></p>
</li>
<li><p><a href="#heading-44-filter-how-deep-how-many-folders-in-you-want-to-search-for-the-file">4.4 Filter how deep (how many folders in) you want to search for the file</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-5-how-to-search-through-hidden-files">How to Search Through Hidden Files</a></p>
</li>
<li><p><a href="#heading-6-how-can-you-know-all-the-properties-that-you-can-use-as-a-filter">How can you know all the properties that you can use as a filter?</a></p>
<ul>
<li><a href="#heading-how-to-retrieve-only-1-desired-property">How to retrieve only 1 desired property</a></li>
</ul>
</li>
<li><p><a href="#heading-7-i-dont-know-the-files-name-but-i-know-whats-inside-it-how-do-i-find-the-file-by-its-content">I don't know the file’s name, but I know what's inside it. How do I find the file by its content?</a></p>
</li>
<li><p><a href="#heading-8-i-cant-see-the-full-path-how-do-i-fix-this">I can't see the full path - how do I fix this?</a></p>
</li>
<li><p><a href="#heading-9-hard-to-read-open-the-results-in-the-text-editor-of-your-choice">Hard to read? Open the results in the text editor of your choice</a></p>
</li>
<li><p><a href="#heading-10-summary-the-ultimate-commands-for-searching-and-finding-whatever-you-need">Summary - the ultimate commands for searching and finding whatever you need</a></p>
</li>
</ol>
<h2 id="heading-1-basic-explanation-of-the-get-childitem-command">1. Basic Explanation of the <code>Get-ChildItem</code> Command</h2>
<p>Let's take a look at the example searching script to understand how it works:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Path "C:\path to\your directory\" -Filter "*whatImLookingFor*"
</code></pre>
<p><code>Get-ChildItem</code> (aliases: <code>dir</code>, <code>ls</code>, <code>gci</code>) lists the content of a folder or directory just like the Linux <code>ls</code> command does.</p>
<p>This command works by searching every single file and directory <strong>in the path specified.</strong> It shows you everything it found that <strong>matches the filter</strong>. It doesn't mean that this command doesn't look everywhere else – because it does.</p>
<p>So you specify the path that is the parent (folder), which means that every folder and file under it is its child. If you know some CSS and JavaScript, treat it the same way that these languages do.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/0bd18776-72bc-46be-bbaa-b616c5ce1c3a.png" alt="Picture: a visual explanation of -depth and -recurse parameters. It shows &quot;Documents&quot; folder at the bottom, which is tagged both as Parent and Depth 0. It points upwords to its child folders and a child file. Those are tagged as Depth 0 children of our Documents folder. They are simultaneously tagged as Depth 1 parents, so files and folders. to which they are pointing upwards, are their Depth 1 children." style="display:block;margin:0 auto" width="821" height="656" loading="lazy">

<p>If you don't use <code>-Recurse</code> or <code>-Depth</code>, then the command works only in your current directory (parent Depth level 0) and searches for its children inside that directory (children Depth level 0).</p>
<p>If you use <code>-Recurse</code>, then the <code>gci</code> will search for what you want on ALL LEVELS. But by using<code>-Depth</code>, you can specify how deep you want it to look for a file/folder.</p>
<p>To recurse means "to repeat an operation". So, <code>-Recurse</code> means that <code>gci</code> will repeat the search for your file or folder in every child element of the <em>"Documents"</em> directory, and every directory inside it, all levels deep.</p>
<p>All of these files and folders are children of your <em>"Documents"</em> folder. If you delete the folder, you delete everything inside it too.</p>
<p><code>-Filter</code> filters the output of the command to only show what matches the filter (examples of how to use filter are further in the article).</p>
<p><code>-Path</code> tells where the command should be looking for files (by using "C:\", for example, you're telling it to look at the very basis of your computer). If you want to search in certain directory it would look like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Path "C:\path to\your directory\"
</code></pre>
<p>OR</p>
<pre><code class="language-powershell">Get-ChildItem -Path "~\Documents\path to\your directory\"
</code></pre>
<p><code>~\</code> here is a shorthand for "inside current user's folder" or <strong>"C:\Users\YourUsername"</strong>.</p>
<p>Next, we can specify whether we'd like to look for a <strong>file</strong> or a <strong>folder</strong>, so we have fewer results to look at:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatImLookingFor*" -File
</code></pre>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatImLookingFor*" -Directory
</code></pre>
<p>You might be wondering how you can stop the search if it takes too long. When you're using <code>-Recurse,</code> the output that you'll get might become quite overwhelming, especially if you didn't specify your command enough (more about that in <a href="#heading-3-when-is-the-path-option-not-needed">step 3</a> and <a href="#heading-4-advanced-searching-combining-getchildren-with-the-whereobject-command">step 4</a>). Luckily, you can stop any command in PowerShell after starting it with <strong>Ctrl + C</strong> OR <strong>Ctrl + Z</strong> OR <strong>Ctrl + X</strong>. All of them should work.</p>
<h2 id="heading-most-used-examples-of-searching-by-gci-command">Most Used Examples of Searching by <code>gci</code> Command</h2>
<p>Here are some handy examples of searching scripts that you can use:</p>
<p><strong>Example #1</strong>: search for all executive files on your PC (remember that you can stop this command with one of shortcuts, like <strong>Ctrl + C</strong>):</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*.exe" -File
</code></pre>
<p>REMEMBER:<br>In order to paste commands into the PowerShell, you have to first enable it. <a href="https://notblackmagic.hashnode.dev/enable-copy-pasting-in-windows-powershell-cli-in-3-steps">Here's how</a>.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406611072/f23a475e-071d-42d6-b300-f442d7f926c9.png" alt="Picture: gci command pasted into PowerShell." style="display:block;margin:0 auto" width="1108" height="645" loading="lazy">

<p>This command will show you a very long list of executable files and their folders (as shown in the image below).</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406617447/c787e9a7-b2b8-4149-84fa-1320f94c7e48.png" alt="Picture: used gci command shows all the executable files it can find." style="display:block;margin:0 auto" width="933" height="542" loading="lazy">

<p>These lists might be so long that it's impossible to find anything in them. That's why you'll learn how to use more advanced techniques of filtering in <a href="#heading-4-advanced-searching-combining-getchildren-with-the-whereobject-command">step 4</a> to see fewer unnecessary results that don't fit your criteria.</p>
<p><strong>Example #2</strong>: search for an executable file that has <em>"notepad"</em> in its name (or search for any program you need, basically):</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "notepad*.exe" -File
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406643047/06e37fba-04d8-4806-839f-19982cd011ea.png" alt="Picture: gci command showing all executable &quot;notepad&quot; files." style="display:block;margin:0 auto" width="1100" height="568" loading="lazy">

<p>One of the results will show you the location of the file you want:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406956329/e7582c25-7c96-4b01-aa36-8660d68a4d37.png" alt="Picture: gci command showing the path to the found executable file." style="display:block;margin:0 auto" width="560" height="106" loading="lazy">

<p>In our case it's the <code>C:\Windows\System32</code> folder.</p>
<p>You can mix it however you want! Thanks to that command, you don't have to remember much about your file and it will still work.</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "n*pad*.*xe"
</code></pre>
<p>So what if you see some errors while scanning the whole system. Should you worry?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406995588/3b23deb1-1530-4fa7-8f52-427386bc37e9.png" alt="Picture: gci command showing error messages while searching for files." style="display:block;margin:0 auto" width="823" height="287" loading="lazy">

<p>It's ok! Sometimes you might get lots of errors. They will most likely occur when a script scours the system folders/files. If you want to get rid of them, add <code>-ErrorAction SilentlyContinue</code>, like you see here:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "notepad*.exe" -File -ErrorAction SilentlyContinue
</code></pre>
<p>You can try it now ;)</p>
<h2 id="heading-2-setup-for-other-more-complex-examples">2. Setup for Other More Complex Examples</h2>
<p>Now, let's look at even more use cases for this command. But first, we'll create a space where I can show you examples.</p>
<p>First, create new folder inside your <em>"Documents"</em> folder. Let's call it <em>"Items"</em>.</p>
<p>Inside it, create two text documents. Name one of them <em>"Item 1- Green Bracelet"</em> and the other <em>"Item 2- Blue Bracelet"</em> (Yes, make sure you write the first letter of each word in <strong>UPPER CASE</strong>).</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407060476/eeb559d5-2627-4674-a780-e23f4e67f5a9.png" alt="Picture: example setup of files inside &quot;Items&quot; folder inside &quot;Documents folder&quot;." style="display:block;margin:0 auto" width="1135" height="288" loading="lazy">

<p>Copy these files now.</p>
<p>Go one folder back (you can use the <strong>Ctrl + UpArrow</strong> shortcut ) and create another folder next to <em>"Items"</em> called <em>"More items"</em>:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407053259/06d23003-aaa5-457b-8356-af10327d3436.png" alt="Picture: example setup. New &quot;More items&quot; folder created next to the &quot;Items&quot; folder." style="display:block;margin:0 auto" width="1059" height="288" loading="lazy">

<p>Paste the copied files inside the "More items" folder and change their names, so they have only <strong>lower case</strong> letters (<em>"item 1- green bracelet"</em> and <em>"item 2- blue bracelet"</em> ).</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407154078/314b476a-8062-4d77-82f0-0ce76cf4c39b.png" alt="Picture: example setup. All files inside &quot;More items&quot; folder have names with only lowercase letters." style="display:block;margin:0 auto" width="1071" height="287" loading="lazy">

<p>PRO TIP:<br>You can click once on a file with your mouse and then type the <strong>F2</strong> key on your keyboard in order to change their names.</p>
<h3 id="heading-3-when-is-the-path-option-not-needed">3. When is the <code>-Path</code> option not needed?</h3>
<p>You don't have to specify the path every time. You can always just move to the desired directory with the <code>cd</code> (change directory) command.</p>
<p>This command will move you to your <code>Documents</code> folder:</p>
<pre><code class="language-powershell">cd ~\Documents\
</code></pre>
<p>Now, you should be able to see PowerShell pointing to your <code>Documents</code> folder on the left of the screen:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/1cd597e0-ae5e-4ea4-b066-b573a3cc2b4b.png" alt="Picture: PowerShell pointing to the Documents folder." style="display:block;margin:0 auto" width="485" height="139" loading="lazy">

<p>If you don't see this, then you can use double quotes <code>" "</code>, like in this command:</p>
<pre><code class="language-powershell">cd "~\Documents\"
</code></pre>
<p>Make sure that PowerShell is pointing to our desired folder. Now, the searching command looks like this without the <code>-Path</code> option:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -File
</code></pre>
<p>Pretty simple, right?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407625727/e83b7cb3-9bdc-420b-b0ae-28fa9a5ceb42.png" alt="Picture: you can first use &quot;cd&quot; command to move to the directory you want. Then you don't have to use  &quot;Path&quot; option in your &quot;gci&quot; command." style="display:block;margin:0 auto" width="593" height="311" loading="lazy">

<p>As you can see in the image above, we first moved to our desired directory, so later we could perform the search inside it without specifying the <code>-Path</code> option/parameter.</p>
<p>But the <code>-Path</code> option is very useful, either when you're creating a script or you want to search for something without moving away from the current directory:</p>
<pre><code class="language-powershell">Get-ChildItem -Path ~\Documents\ -Recurse -Filter "*item*" -File
</code></pre>
<pre><code class="language-powershell">Get-ChildItem -Path ~\Documents\ -Recurse -Filter "*item*" -Directory
</code></pre>
<p>Here's an example. I'm inside the <code>System32</code> folder and I want to know whether the thing I'm looking for is inside the <code>Documents</code> folder without moving in there:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407360312/60915425-8432-40c3-a395-a59e1b363667.png" alt="Picture: &quot;gci&quot; command can looks for a file in a specific directory without moving us to this directory. All thanks to &quot;Path&quot; option." style="display:block;margin:0 auto" width="640" height="205" loading="lazy">

<p>And it really is there!</p>
<p>From now on, because you already know what the <code>-Path</code> option is being used for, I won't be using it unless it's necessary.</p>
<h2 id="heading-4-advanced-searching-combining-get-childitem-with-the-where-object-command">4. Advanced Searching – Combining <code>Get-ChildItem</code> with the <code>Where-Object</code> Command</h2>
<p>Sometimes you might have several folders named exactly the same, but they're in different places. You might want to exclude them based on their content, which folder they are in, or based on their<code>-Depth</code> level (see the graphic with the explanation about <code>-Depth</code> level in <a href="#heading-1-basic-explanation-of-the-get-childitem-command">step 1</a>). That's what we're going to cover in the next few points.</p>
<p>For this part of the tutorial, make sure you've gone through <a href="#heading-2-setup-for-other-more-complex-examples">step 2</a> (but you can skip step 3 if you want).</p>
<h3 id="heading-41-searching-through-only-a-particular-directory">4.1. Searching through only a particular directory</h3>
<p>Let's say that we're now looking for the bracelets that we created in <strong>step 2</strong>. But, we want to see the results from only one folder. For that, we'll use case-sensitive search (<code>-clike</code>) to get only our preferred results. But <code>-clike</code> doesn't work with <code>gci</code> alone. We need to apply another filter with the <code>Where-Object { }</code> command:</p>
<pre><code class="language-powershell">Get-ChildItem -Path ~\Documents\ -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*Item*" }
</code></pre>
<p>OR (clearer version, without the <code>-Path</code> option):</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*Item*" }
</code></pre>
<p>Let's review what's going on here:</p>
<ul>
<li><p><code>Get-ChildItem -Recurse -Filter "*item*"</code> searches for all files and folders with "item" in their name</p>
</li>
<li><p><code>|</code> – the "pipe" symbol is used to get the output of the previous command (the list of all files and folders filtered by <code>gci</code>) and send it to the next command (<code>Where-Object</code> is applying another filter to what is already filtered by <code>gci</code>).</p>
</li>
<li><p><code>Where-Object { }</code> is the command used for filtering the lists of objects. The filter is being specified inside the <code>{ }</code> curly brackets.</p>
</li>
<li><p><code>\(_</code> refers to all the separate objects. Treat it as <em>"ForEachObjectFromList".</em> And treat the whole sequence after the <code>|</code> as <em>"FindObjectsFromList that have a name with 'Item' "</em>.<br><code>\)_</code> is very often used with <code>Where-Object</code>, but also with some other commands.</p>
</li>
<li><p><code>.Name</code> – we choose a Name property to get from every object.</p>
</li>
<li><p><code>-clike</code> finds a match that is 100% correct. All letters must be the exact same case as the phrase we specified. <code>c</code> stands for "case sensitive" and it checks every letter to see if it's <strong>upper case</strong> or <strong>lower case</strong>.</p>
</li>
</ul>
<p>So, <code>Where-Object { $_.Name -clike "*Item*" }</code> is a filter that takes the <code>Name</code> parameter of every object from the list (created by <code>gci</code>) and checks with <code>-clike</code> if any <code>Name</code> has the word "Item" in it.</p>
<p>As you can see in the image below, now we'll get only the files with <strong>upper case</strong> names in our result:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408072301/d2ffaae0-f688-40c6-9c1e-4ddd37159146.png" alt="command looking for file in specific directory case-sensitive" style="display:block;margin:0 auto" width="697" height="391" loading="lazy">

<p>IMPORTANT:<br><code>-like</code> alone means that we're looking for a certain pattern, no matter what case the letters are. The <code>c</code> in <code>-clike</code> means that we look for the thing with exactly the same capitalization of the letters (both upper and lower case, hence the <em>"c"</em>).</p>
<p>If you want to see the files <strong>without the upper case</strong> first letter, you can do that by changing "*Item*" from our current command to "*item*":</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*item*" }
</code></pre>
<p>Let's try it out!</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407562860/84b82488-8ee5-4b23-ac8f-831d9c586c41.png" alt="Picture: command looking for files with only lowercase letters in their names" style="display:block;margin:0 auto" width="747" height="321" loading="lazy">

<h3 id="heading-42-how-to-search-while-excluding-a-particular-directory">4.2. How to search while excluding a particular directory</h3>
<p>In <strong>step 4.1</strong> we learned how to search only for files/folders with specific case-sensitive names in them. After applying only two changes to our previous code, we can exclude certain directories from our search.</p>
<p>Here's our starting command once again:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*Item*" }
</code></pre>
<h4 id="heading-change-1">Change #1</h4>
<p>In the example above, <code>-clike</code> shows only files/folders <strong>including</strong> specific phrase in their names. If we change it to <code>-cnotlike</code>, we'll <strong>exclude</strong> from the search all files/folders with that specific phrase in their name.</p>
<p>Now our code looks like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -cnotlike "*Item*" }
</code></pre>
<h4 id="heading-change-2">Change #2</h4>
<p>After the first change, <code>Where-Object { \(_.Name -cnotlike "*Item*" }</code> only excludes the names, not full paths. In order to avoid that, we need to exclude an actual path to these files. We can do that by changing <code>\)_.Name</code> to <code>$_.FullName</code>, which checks for a certain phrase in the whole path to the file <strong>and</strong> in the file's name.</p>
<p>Now, your command should look like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.FullName -cnotlike "*Item*" }
</code></pre>
<p>We excluded the "Items" folder from our search. You should now be able to see the files only from the "More items" directory. Try it out yourself!</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/547801a8-a825-4607-9dbe-42e3c4af238e.png" alt="Picture: excluding part of path with FullName -cnotlike." style="display:block;margin:0 auto" width="1054" height="371" loading="lazy">

<p>What if you want to exclude the "More items" directory instead? Just change the phrase inside the filter to something like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |   
Where-Object { $_.FullName -cnotlike "*More*" }
</code></pre>
<p>We also changed the name of the file from "*item*" to "*green*" in our <code>gci</code> search (first line of code). That's why now we'll see only one bracelet in our result list:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408276671/f4043a3f-ba14-4ff4-be60-ad5c3e321003.png" alt="command looking for file with exclusion case-sensitive" style="display:block;margin:0 auto" width="1000" height="182" loading="lazy">

<p>The <code>gci</code> command has two filters applied. First, it searches for files with phrase "green" in their names. The second filter is the "Where-Object" command, which <strong>excludes</strong> anything that has the word "More" in its path. In our case, the "More items" folder got excluded.</p>
<p>We don't even need the case-sensitive filter in our case. The command will work the same when we <strong>exclude</strong> just a <strong>lowercase</strong> word "more". So let's change <code>-cnotlike "*More*"</code> to <code>-notlike "*more*"</code> and see if it's true:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |   
Where-Object { $_.FullName -notlike "*more*" }
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408250133/f6dac0bd-fc74-4c24-8b4b-9ba780683956.png" alt="Picture: case-sensitive search working the same in current example as a not case-sensitive search." style="display:block;margin:0 auto" width="836" height="188" loading="lazy">

<p>As you can see, the result is the same! Despite different cases of the letters, we still got the right <strong>keyword</strong>. So, case-sensitive search isn't always needed&nbsp;– only when you want to be very specific.</p>
<p>Sometimes, being too specific might be bad and make your code not work as intended. To see what I mean, let's look at the example below. Let's apply case-sensitive search once again, but to our unchanged, lowercase keyword "more" and see if it still works:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |   
Where-Object { $_.FullName -cnotlike "*more*" }
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408273177/ee37dcf7-8720-44b7-aa04-1c09e1cfbe52.png" alt="Picture: case-sensitive search doesn't filter out anything now, because it's too specific. &quot;More items&quot; folder omits the filter now." style="display:block;margin:0 auto" width="1011" height="271" loading="lazy">

<p>Case-sensitive search doesn't filter out anything now, because it's too specific. Both the "Items" and "More items" folders omit the filter now.</p>
<h4 id="heading-faq">FAQ:</h4>
<p>If the <code>Where-Object</code> command is what actually filters the output for us, shouldn't we drop (delete) the <code>-Filter</code> option from <code>gci</code>?</p>
<p>No, we should still use the <code>-Filter</code> option, because it already separates around 99% of the possible files, so the <code>Where-Object</code> command has to work roughly only on 1% of the objects. It makes this part of the command AT LEAST 100 times faster (more often 100,000 times or even faster).</p>
<p>You can try using this command in <code>-Path C:/</code> with and without the <code>-Filter</code> option. In my case, using the <code>-Filter</code> shortened the time needed for the whole sequence of commands to finish from 16 seconds to 8 seconds (first 7.99 seconds is used by <code>gci</code>, so that's why the time got shortened only by a half). That's what we call ✨<em>optimization</em>✨ :D</p>
<h3 id="heading-43-searching-only-1-directory-from-many-with-exactly-the-same-name">4.3 Searching only 1 directory from many with exactly the same name</h3>
<p>We've learned how to search for a phrase anywhere inside the path of a file. But what if we want to search inside exactly the "More items" folder? For that, we'll use the <code>-match</code> filter (which works similarly to the <code>-like</code> filter).</p>
<p>Our phrase will also use "\", instead of "\". This is because "\" is the symbol for a folder, but alone in programming it also has some other features, which we don't want.</p>
<p>This command will look for a match for the "More items" folder in the path of every file from the list. Then, it will show you this file if it matches.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/028f1187-e371-4496-8152-df778337a465.png" alt="Picture: &quot;gci&quot; with a filter for an exact folder." style="display:block;margin:0 auto" width="908" height="155" loading="lazy">

<p>What if we want to check for two folders, one next to the other, simultaneously? Very easy! Just connect them with the sign for a folder "\". Here, the command will search inside the "More items" folder only if it's inside the "Documents" folder:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/6faef3fa-e2a8-46ff-bd5c-7c9cc27c1ab8.png" alt="Picture: searching for &quot;DocumentsMore*&quot;" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>As you can see, we didn't use "More items", only "More". You can shorten that filter how you want. It will still be applied to the whole path. See the example below:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.FullName -match "s\\Mo*" }
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/817fb9cb-ddd3-4d9f-8ff5-2dbbc3eb7d0d.png" alt="Picture: filter works, even if it could be more specific" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>Earlier, we used the <code>not</code> statement in <code>-like</code> filter to exclude certain files and directories. The same can be done with <code>-notmatch</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File | 
Where-Object { $_.FullName -notmatch "ents\\Ite*" }
</code></pre>
<p>Be aware that we're now excluding the "Items" folder from the search, not "More items".</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/acf4d31f-9c8f-4092-b0bd-ed577ca982cf.png" alt="Picture: excluding &quot;Documentstems&quot; folders from search by using &quot;notmatch&quot; filter" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>And, with <code>-cmatch</code> we can apply the same case-sensitive filter as with <code>-clike</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File | 
Where-Object { $_.FullName -cmatch "green*" }
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/7db7d3bb-9a9e-4881-b0e9-4a119c3f93d8.png" alt="7db7d3bb-9a9e-4881-b0e9-4a119c3f93d8" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>I hope you get the gist of it now.</p>
<h3 id="heading-44-filter-how-deep-how-many-folders-in-you-want-to-search-for-the-file">4.4 Filter how deep (how many folders in) you want to search for the file</h3>
<p>Sometimes you might have a very long path to some of your files. If you don't want to waste time searching every folder on your computer recursively, you can use <code>-Depth</code> option. It specifies how many folders to search inside your folder tree. I already showed you the picture of a folder tree in the beginning of this article, but you should take a look at it here once again.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/7a433d89-cfe5-4bf6-8f6d-b7b812414a93.png" alt="Picture: a visual explanation of -depth and -recurse parameters. It shows &quot;Documents&quot; folder at the bottom, which is tagged both as Parent and Depth 0. It points upwords to its child folders and a child file. Those are tagged as Depth 0 children of our Documents folder. They are simultaneously tagged as Depth 1 parents, so files and folders. to which they are pointing upwards, are their Depth 1 children." style="display:block;margin:0 auto" width="821" height="656" loading="lazy">

<p>So, how does the <code>-Depth</code> parameter work?</p>
<p><code>-Depth 0</code> means that our command will search only the current folder. It will show results of all children of Depth level 0. Those results are:<br>1 "child file" and 2 "child folders".</p>
<p><code>-Depth 1</code> searches the current folder and its child-folders. It will show the results of all children of Depth level 1. Those results are:<br>1 "child file", 2 "child folders", 2 "grandchild files" and 1 "grandchild folder".</p>
<p><code>-Depth 2</code> searches the current folder and its child and grandchild folders. It will show results of all children of Depth level 2. Those results are:<br>1 "child file", 2 "child folders", 2 "grandchild files", 1 "grandchild folder" and 1 "great grandchild file".</p>
<p>Let's see the difference between these two commands:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -Depth 0
</code></pre>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -Depth 1
</code></pre>
<p>The first command will show you only the files and folders inside our current directory.<br>The second command will also search for them inside every folder found inside the current folder.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408448152/2d875580-e463-4903-b99f-d3b457f5eb5a.png" alt="depth parameter explanation" style="display:block;margin:0 auto" width="612" height="583" loading="lazy">

<p>For the sake of practice, let's combine it with <code>Where-Object</code> to find the green bracelet:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -Depth 1 | Where-Object { $_.name -clike"*green*" }
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408504470/4af154e6-9d24-43e4-b228-770da7de9151.png" alt="Picture: gci looking for file with set depth" style="display:block;margin:0 auto" width="812" height="162" loading="lazy">

<p>I hope that this example showed you how easy it is to use multiple options ( <code>-Depth</code>, <code>-Recurse</code>) and filters (<code>-Filter</code>, <code>Where-Object</code>).</p>
<h2 id="heading-5-how-to-search-through-hidden-files">5. How to Search Through Hidden Files</h2>
<p>Some files are not that easily accessible to the user. You can see some of the hidden files and folders in Windows Explorer (<a href="https://notblackmagic.hashnode.dev/how-to-see-hidden-files-and-folders-in-windows-file-explorer">here's how</a>). But sometimes it's easier to find what you need if you see <strong>only</strong> those hidden files. That's possible with PowerShell.</p>
<p>The options we're going to use for that are:</p>
<ul>
<li><p><code>-Force</code>: show files otherwise not accessible by the user, such as hidden files.</p>
</li>
<li><p><code>-Hidden</code>: show <strong>only</strong> those hidden files and directories.</p>
</li>
</ul>
<p>This example will search for hidden files in our user's folder:</p>
<pre><code class="language-powershell">gci -Path ~\ -Force -Hidden
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408531210/22319e7b-f9f1-40cb-8037-7abcb9225c3b.png" alt="Picture: gci with -Forece and -Hidden parameters showing hidden files and folders" style="display:block;margin:0 auto" width="1315" height="553" loading="lazy">

<p>Everything here is usually invisible to the typical user. But not for you now :D</p>
<p>The interesting thing is that there are more files not available to the user than the available ones. If you're brave enough, you can see them yourself (Remember! <strong>Ctrl + C</strong> stops the command!):</p>
<pre><code class="language-powershell">gci -Path ~\ -Force -Hidden -Recurse
</code></pre>
<h2 id="heading-6-how-can-you-know-all-the-properties-that-you-can-use-as-a-filter">6. How can you know all the properties that you can use as a filter?</h2>
<p>Up until now, we'vce used some common properties, like <code>Name</code> and <code>Fullname</code>. But there are many others that you might want to access, like <code>CreationTime</code> (date of creating the file) or <code>LastWriteTime</code> (date of last edit of the file).</p>
<p>In this section, I'll first show you how to see all the possible properties. After that, you'll learn how to retrieve only the property you want for scripting purposes.</p>
<p>Go through <strong>step 2</strong> above if you haven't already, because we're going to use the same files that we created before.</p>
<p>Move to the <code>Documents</code> folder in PowerShell.</p>
<p>I hope that this script looks familiar to you now. It searches for files with "item" in their names and checks if these names contain the word "green" (all lowercase letters):</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" | 
Where-Object { $_.Name -clike "*green*" }
</code></pre>
<p>We know that only one file should appear (if you don't trust me, just see for yourself). So, we're going to see every possible property we can use by appending (adding at the end) this fragment of code:<br><code>| Select-Object -Property *</code></p>
<p><code>Select-Object</code> (alias: <code>select</code>) is used for selecting different types of properties. By using an option <code>-Property</code> we tell it to show both values and names of all the properties.</p>
<p>For example:</p>
<p>Name of property: <code>FullName</code><br>Value of property: <code>~\Documents\More items\item 1- green bracelet.txt</code></p>
<p>The asterisk <code>*</code> at the end tells this command to show these names and values for every property possible.</p>
<p>The final version of this command looks like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" | 
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -Property *
</code></pre>
<p>Try finding the <code>FullName</code> property in there :D</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408562185/710a4835-a415-4500-af4f-6371a83ae3b6.png" alt="getting all command options or properties" style="display:block;margin:0 auto" width="962" height="830" loading="lazy">

<p>This command showed us all possible properties that we can use for that 1 file that it found. If there were more files fitting the filter, then every single one of them would have a similar list of properties. But for different types of files you will get different results.</p>
<h3 id="heading-how-to-retrieve-only-1-desired-property">How to retrieve only 1 desired property</h3>
<p>You've already learned how to check for all possible properties. So, how do we use any of them? Just put one of them instead an asterisk <code>*</code> at the end of the command, like we put <code>CreationTime</code> in here:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -Property CreationTime
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/060de2e6-909f-4679-987c-e715fb7ee19b.png" alt="Picture: Select-Object shows only the CreationTime property" style="display:block;margin:0 auto" width="1120" height="182" loading="lazy">

<p>You can use any other property for the sake of this exercise, like <code>LastWriteTime</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -Property LastWriteTime
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/8223f738-9467-4961-8518-82e010a6e425.png" alt="Picture: Select-Object shows only the LastWriteTime property" style="display:block;margin:0 auto" width="1112" height="184" loading="lazy">

<p>What if you want to retrieve only the value of the property without its name (because you already know its name and it also messes up your script)? You can retrieve just the value, by changing the <code>-Property</code> to <code>-ExpandProperty</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -ExpandProperty LastWriteTime
</code></pre>
<p>See the result:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/7f08ecfd-4a19-4276-9d2a-eac53d5cdb93.png" alt="Picture: Changing -Property to -ExpandProperty makes the script to show only the value of the property without its name. the" style="display:block;margin:0 auto" width="1114" height="170" loading="lazy">

<h2 id="heading-7-i-dont-know-the-files-name-but-i-know-whats-inside-it-how-do-i-find-the-file-by-its-content">7. I don't know the file’s name, but I know what's inside it. How do I find the file by its content?</h2>
<p>Sometimes it's easier to find a file by searching it by its content. Or perhaps you have lots of similar files and you'd like to check them quickly without opening and closing them. I'll show you some techniques that will let you achieve that in no time.</p>
<p>This command will search every file on your system for the specified word or phrase (in our case, the phrase is "match"):</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -File | 
Select-String -Pattern 'match' -List
</code></pre>
<p>Here's what's happening:</p>
<ul>
<li><p><code>Get-ChildItem -Path C:\ -Recurse -File</code>: as you already know, this part searches for every file on your computer.</p>
</li>
<li><p><code>|</code> – passes the list of files to the next command. So, the next command will search for a certain phrase only in the files listed by <code>gci</code>.</p>
</li>
<li><p><code>Select-String</code> – "String" is a common word in programming used to describe a word/phrase/some text. So, we select the phrase that we want to search for. That phrase is specified by the <code>-Pattern</code> parameter (in our case it's "match").</p>
</li>
<li><p><code>-List</code> tells the command to show only the first found match in every file (great if you want to just see the list of all found files).</p>
</li>
</ul>
<p>Here's an example output of our command:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/cb91d732-c15a-4b10-a127-1a78af2efe63.png" alt="Picture: Select-String showing path to the file and the place in the file where the pattern was found." style="display:block;margin:0 auto" width="1119" height="127" loading="lazy">

<p>Of course, you have quite a lot of files, and some images may also appear in your search (like .svg files that are basically text files that tell the system how to draw an icon). So, it's always best to specify what type of file you're searching for. Let's look for the phrase "red" inside .svg files:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.svg" -Recurse | 
Select-String -Pattern 'red' -List
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/c1d33d66-014b-49af-9e8d-508a581d51fa.png" alt="Picture: gci looking for text inside svg graphic files." style="display:block;margin:0 auto" width="1300" height="250" loading="lazy">

<p>On the other hand, some text documents will never appear in your search (for example .doc and .docx documents are encoded in such a way that they're impossible to decode without Word).</p>
<p>But in regular text files, you can search for phrases with an emphasis on big and small letters with the <code>-CaseSensitive</code> option. Here, we're going to search for the phrase "github" with only lowercase letters:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List -CaseSensitive
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/affa057b-3fc4-402d-9f93-4a1b93bcb98f.png" alt="affa057b-3fc4-402d-9f93-4a1b93bcb98f" style="display:block;margin:0 auto" width="1116" height="325" loading="lazy">

<p>Other options that you'll often use with the <code>Select-String</code> command are:</p>
<ul>
<li><code>Select-String -AllMatch</code> will show you all matches found in every searched file (instead of only 1 match found per file, like with <code>-List</code>).<br><code>Select-String -Context 3</code> shows the three lines of text before and after the line in which the match is found.<br><code>Select-String -Raw</code> won't show you the paths, just the content of the files. This is great for automation and scripts. It's often combined with the <code>-Context</code> option.</li>
</ul>
<p>Let's see some of these options in action:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -AllMatch -Context 3
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771434300827/4a61d396-755c-49e8-a1f5-f0fd24347b5c.png" alt="looking for file based on its content" style="display:block;margin:0 auto" width="1920" height="350" loading="lazy">

<p>Thanks to the <code>-Context</code> parameter, you can see a total of seven lines (three lines before and three lines after the match) in this file, one after another. This makes it easier to differentiate it from all the other matches found by <code>-AllMatch</code> that might be put in a very similar context.</p>
<p>If you ever feel like there's too much clutter on your screen, you can combine <code>Select-String</code> with <code>Select-Object</code> to get only the paths of the files with matched phrases.</p>
<p>The command below will search every .txt file on your computer for the phrase specified:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List
</code></pre>
<p>Let's add the <code>Select-Object -Property Path</code> filter at the end. Now, the command will only show the paths, so there's less clutter on your screen:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List | 
Select-Object -Property Path
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/71b9b173-3dd4-40a1-911f-0d494c6a44bb.png" alt="Picture: adding Select-Object makes the results more readable and easier to understand." style="display:block;margin:0 auto" width="1051" height="585" loading="lazy">

<p>Some of the paths are not fully visible. We'll fix that in the next step.</p>
<h2 id="heading-8-i-cant-see-the-full-path-how-do-i-fix-this">8. I can't see the full path - how do I fix this?</h2>
<p>Let's format the results with the <code>Format-Table -Wrap -AutoSize</code> command. <code>-Autosize</code> allows the result to take the whole available space. <code>-Wrap</code> allows wrapping (continuing the text in the next line when it doesn't fit in the space available), which creates more space if it's needed.</p>
<p>Here's an example:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List | 
Select -Property Path | 
Format-Table -Wrap -AutoSize
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/bcea9d57-e966-4c41-8d24-bfb3acf95281.png" alt="bcea9d57-e966-4c41-8d24-bfb3acf95281" style="display:block;margin:0 auto" width="1057" height="426" loading="lazy">

<p>Now, you can see the whole paths (or any other results you need) even in PowerShell!</p>
<h2 id="heading-9-hard-to-read-open-the-results-in-the-text-editor-of-your-choice">9. Hard to read? Open the results in the text editor of your choice</h2>
<p>You can send the results of any script/command in two ways:</p>
<p><code>&gt; ~\Documents\command_output.txt</code><br>AND<br><code>| Out-File ~\Documents\command_output.txt</code></p>
<p>Both of these will create a file inside your <code>Documents</code> folder, which you can later open in any program of your choice and edit.</p>
<p>Just add whichever solution you prefer to the end of your command, like here:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'match' -List | 
Select -Property Path | 
Out-File ~\Documents\command_output.txt
</code></pre>
<p>In the image below, first you'll see the same command, but without exporting the results to another file. The second command, at the bottom of the image, will export the results to the other file without showing them in PowerShell:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771431975160/41f0445e-1a0c-41f6-83b5-446f21e9bea9.png" alt="Picture: gci looking for file based on its content, but showing only paths to the files with found matches." style="display:block;margin:0 auto" width="1920" height="650" loading="lazy">

<p>You'll see the results from second command after opening the file in any text editor:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771432401191/851770d9-6596-4081-b537-45bf8373ac44.png" alt="Picture: command results are possible to open in any text editor." style="display:block;margin:0 auto" width="1920" height="650" loading="lazy">

<p>But, what if you can't see the full path even in your text editor?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771432650086/f21b1bf6-6aad-4799-a34b-c5889b8f8ee7.png" alt="Picture: command results don't show all information you need. They sometimes stop showing, if it's more then default settings allow for." style="display:block;margin:0 auto" width="700" height="650" loading="lazy">

<p>To address this, you can add <code>| Format-Table -Wrap -AutoSize</code> right before sending the results to the file:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Filter "*.txt" -Recurse | 
Select-String -Pattern 'match' -List | 
Select -Property Path | 
Format-Table -Wrap -AutoSize |
Out-File ~\Documents\command_output.txt
</code></pre>
<p>And open the file to see the whole path!</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771434628016/b8bebea1-f6db-4d12-a0f5-70ca18492b9b.png" alt="Picture: bug fixed. Now, you can see all the information." style="display:block;margin:0 auto" width="1920" height="350" loading="lazy">

<p>Just remember that you have to copy each line one by one. Where you see the arrows in the screenshot above is a "newline" character, which you have to delete. Only after doing that can you copy the whole path and paste it into Windows Explorer or into some script.</p>
<h2 id="heading-10-summary-the-ultimate-commands-for-searching-and-finding-whatever-you-need">10. Summary: the Ultimate Commands for Searching and Finding Whatever You Need</h2>
<p><a href="https://github.com/NotBlackMagician/NBM-cheat-sheets/blob/main/windows_powershell/NBM_cheat_sheet_Get-ChildItem_find_any_file_like_on_linux.txt">Here</a> you can download a free cheat sheet with explanations of the commands and examples in one place.</p>
<h3 id="heading-most-used-commands">Most used commands:</h3>
<ul>
<li>Case-sensitive search:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatYouNeed*" |   
Where-Object { $_.Name -clike "*whatYouNeed*" } |   
Select-Object { $_.FullName } |
Format-Table -Wrap -AutoSize
</code></pre>
<ul>
<li>Alternatively, send the result to a file:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatYouNeed*" |   
Where-Object { $_.Name -clike "*whatYouNeed*" } |   
Select-Object { $_.FullName } |
Format-Table -Wrap -AutoSize |
Out-File ~\Documents\command_output.txt
</code></pre>
<ul>
<li>Search by file's content:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse | 
Select-String -Pattern 'what you remember' -AllMatch -Context 2 |
Format-Table -Wrap -AutoSize
</code></pre>
<ul>
<li>Alternatively, send the result to the file:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse | 
Select-String -Pattern 'what you remember' -CaseSensitive -AllMatch -Context 2 |
Format-Table -Wrap -AutoSize |
Out-File ~\Documents\command_output.txt
</code></pre>
<p>These commands should work for anything you want to find. I hope you understand now how they function after reading through this tutorial ;)</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>If you want to learn more about these commands, I show you how to work with them in depth in my tutorial <a href="https://notblackmagic.hashnode.dev/learn-windows-powershell-commands-like-a-linux-user">“Learn PowerShell commands like a Linux user”</a>.</p>
<p>If what you found here helped you in any way, consider following me on my social media in order to help me reach further audience: <a href="https://social.linux.pizza/@SecretDevil">Mastodon</a>, <a href="https://www.linkedin.com/in/piotr-opoka-4320143a5/">LinkedIn</a>.</p>
<p>You can also rate me on <a href="https://github.com/NotBlackMagician">Github</a> and support me on <a href="https://ko-fi.com/piotropoka">Ko-fi!</a></p>
<p>Thank you for any support you're able to give. Have a great day!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Flutter CI/CD Pipeline with GitHub Actions: Quality Gates, Environments, and Store Deployment ]]>
                </title>
                <description>
                    <![CDATA[ Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build. One of the major improv ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-flutter-ci-cd-pipeline-with-github-actions-quality-gates-environments-and-store-deployment/</link>
                <guid isPermaLink="false">69bb2e078c55d6eefb6c2e8d</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github copilot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD pipelines ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 18 Mar 2026 22:58:15 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8c9d9384-ff02-47d7-aa69-42db2ebae247.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build.</p>
<p>One of the major improvements has been a properly automated CI/CD pipeline flow that gives us seamless automation, continuous integration, and continuous deployment.</p>
<p>In this article, I'll break down how you can automate and build a production ready CI/CD pipeline for your Flutter application using GitHub Actions.</p>
<p>Note that there are other ways to do this, like with Codemagic (built specifically for Flutter apps – which I'll cover in a subsequent tutorial), but in this article we'll focus on GitHub Actions instead.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-typical-workflow">The Typical Workflow</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-writing-the-workflows">Writing the Workflows</a></p>
<ul>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
<ul>
<li><p><a href="#heading-script-1-generateconfigsh">generate_config.sh</a></p>
</li>
<li><p><a href="#heading-script-2-qualitygatesh">quality_gate.sh</a></p>
</li>
<li><p><a href="#heading-script-3-uploadsymbolssh-sentry">upload_symbols.sh</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-workflow-1-prchecksyml">PR Quality Gate (pr_checks.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-2-androidyml">Android CI/CD Pipeline (android.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-3-iosyml">iOS CI/CD Pipeline (ios.yml)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-secrets-and-configuration-reference">Secrets and Configuration Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-the-typical-workflow">The Typical Workflow</h2>
<p>First, let's define the common approach to deploying production-ready Flutter apps.</p>
<p>The development team does their work on local, pushes to the repository for merge or review, and eventually runs <code>flutter build apk</code> or <code>flutter build appbundle</code> to generate the apk file. This then gets shared with the QA team manually, or deployed to Firebase app distribution for testing. If it's a production move, the app bundle is submitted to the Google Play store for review and then deployed.</p>
<p>This process is often fully manual with no automated checks, validation, or control over quality, speed, and seamlessness. Manually shipping a Flutter app starts out relatively simply, but can quickly and quietly turn into a liability. You run <code>flutter build</code>, switch configs, sign the build, upload it somewhere, and hope you didn’t mix up staging keys with production ones.</p>
<p>As teams grow and release updates more and more quickly, these manual steps become real risks. A skipped quality check, a missing keystore, or an incorrect base URL deployed to production can cost hours of debugging or worse – it can affect your users.</p>
<p>Automating this process fully involves some high level configuration and predefined scripting. It completely takes control of the deployment process from the moment the developer raised a PR into the common or base branch (for example, the <code>develop</code> branch).</p>
<p>This automated process takes care of everything that needs to be done – provided it has been predefined, properly scripted, and aligns with the use case of the team.</p>
<h3 id="heading-what-well-do-here">What we'll do here:</h3>
<p>In this tutorial, we'll build a production-grade CI/CD pipeline for a Flutter app using GitHub Actions. The pipeline automates the entire lifecycle: pull-request quality checks, environment-specific configuration injection, Android and iOS builds, Firebase App Distribution for testers, Sentry symbol uploads, and final deployment to the Play Store and App Store.</p>
<p>By the end, every release – from a developer opening a PR to the final build landing in users' hands – will be fully automated, with no one touching a terminal.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ol>
<li><p>A Flutter app with working Android and iOS builds</p>
</li>
<li><p>Basic familiarity with <a href="https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/">GitHub Actions</a> (workflows and jobs)</p>
</li>
<li><p>A Firebase project with App Distribution enabled</p>
</li>
<li><p>A Sentry project for error tracking</p>
</li>
<li><p>A Google Play Console app already created</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>Fastlane configured for your iOS project</p>
</li>
<li><p>Basic Bash knowledge (I’ll explain the important parts)</p>
</li>
</ol>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>In this guide, we'll be building a CI/CD pipeline with very precise instructions and use cases. These use cases determine the way your pipeline is built.</p>
<p>For this tutorial, we'll use this use case:</p>
<p>I want to automate the workflow on my development team based on the following criteria:</p>
<ol>
<li><p>When a developer on the team raises a PR into the common working branch <code>develop</code> in most cases), a workflow is triggered to run quality checks on the code. It only allows the merge to happen if all checks (like tests coverage, quality checks, and static analysis) pass.</p>
</li>
<li><p>Code that's moving from the develop branch to the staging branch goes through another workflow that injects staging configurations/secret keys, does all the necessary checks, and distributes the application for testing on Firebase App Distribution for android as well as Testflight for iOS.</p>
</li>
<li><p>Code that's moving from the staging to the production branch goes through the production level workflow which involves apk secured signing, production configuration injection, running tests to ensure nothing breaks, Sentry analysis for monitoring, and submission to App Store Connect as well as Google Play Console.</p>
</li>
</ol>
<p>These are our predefined conditions which help with the construction of our workflows.</p>
<h2 id="heading-writing-the-workflows">Writing the Workflows</h2>
<p>We'll split this pipeline into three GitHub Actions workflows.</p>
<p>We'll also be taking it a notch higher by creating three helper .sh scripts for a cleaner and more maintainable workflow.</p>
<p>In your project root, create two folders:</p>
<ol>
<li><p>.github/</p>
</li>
<li><p>scripts.</p>
</li>
</ol>
<p>The <strong>.github/</strong> folder will hold the workflows we'll be creating for each use case, while the <strong>scripts/</strong> folder will hold the helper scripts that we can easily call in our CLI or in the workflows directly.</p>
<p>After this, we'll create three workflow .yaml files:</p>
<ol>
<li><p>pr_checks.yaml</p>
</li>
<li><p>android.yaml</p>
</li>
<li><p>ios.yaml</p>
</li>
</ol>
<p>Also in the scripts folder, let's create three .sh files:</p>
<ol>
<li><p>generate_config.sh</p>
</li>
<li><p>quality_checks.sh</p>
</li>
<li><p>upload_symbols.sh</p>
</li>
</ol>
<pre><code class="language-yaml">.github/
  workflows/
    pr_checks.yml
    android.yml
    ios.yml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh
</code></pre>
<p>This workflow architecture ensures that a push to <code>develop</code> automatically produces a tester build. Also, merging to <code>production</code> ships directly to the stores without manual commands or config changes.</p>
<p>The scripts live outside the YAML on purpose. This lets you run the same logic locally.</p>
<h3 id="heading-the-helper-scripts">The Helper Scripts</h3>
<p>The scripts form the backbone of the pipeline. Each one has a single responsibility and is reused across workflows.</p>
<p>Instead of cramming logic into YAML, we'll move it into <strong>reusable scripts</strong>. This keeps workflows clean and lets you run the same logic locally. Let's go through each one now.</p>
<h3 id="heading-script-1-generateconfigsh">Script #1: <code>generate_config.sh</code></h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile apps.</p>
<p>The strategy:</p>
<ul>
<li><p>Commit a Dart template file with placeholders</p>
</li>
<li><p>Replace placeholders at build time using secrets from GitHub Actions</p>
</li>
<li><p>Never commit real credentials</p>
</li>
</ul>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail


ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "Generated config for $ENV_NAME"
</code></pre>
<p>This script is responsible for injecting environment-specific configuration into the Flutter app at build time, without ever committing secrets to source control.</p>
<p>Let’s walk through it carefully.</p>
<h4 id="heading-1-shebang-choosing-the-shell">1. Shebang: Choosing the Shell</h4>
<pre><code class="language-yaml">#!/usr/bin/env bash
</code></pre>
<p>This line tells the system to execute the script using <strong>Bash</strong>, regardless of where Bash is installed on the machine.</p>
<p>Using <code>/usr/bin/env bash</code> instead of <code>/bin/bash</code> makes the script more portable across local machines, GitHub Actions runners, and Docker containers.</p>
<h4 id="heading-2-fail-fast-fail-loud">2. Fail Fast, Fail Loud</h4>
<pre><code class="language-yaml">set -euo pipefail
</code></pre>
<p>This is one of the most important lines in the script.</p>
<p>It enables three strict Bash modes:</p>
<ul>
<li><p><code>-e</code>: Exit immediately if any command fails</p>
</li>
<li><p><code>-u</code>: Exit if an undefined variable is used</p>
</li>
<li><p><code>-o pipefail</code>: Fail if any command in a pipeline fails, not just the last one</p>
</li>
</ul>
<p>This matters in CI because silent failures are dangerous, partial config generation can break production builds, and CI should stop immediately when something is wrong.</p>
<p>This line ensures that no broken config ever makes it into a build.</p>
<h4 id="heading-3-reading-input-arguments">3. Reading Input Arguments</h4>
<pre><code class="language-yaml">
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
</code></pre>
<p>These lines read <strong>positional arguments</strong> passed to the script:</p>
<ul>
<li><p><code>$1</code>: Environment name (<code>dev</code>, <code>staging</code>, <code>production</code>)</p>
</li>
<li><p><code>$2</code>: API base URL</p>
</li>
<li><p><code>$3</code>: Encryption or API key</p>
</li>
</ul>
<p>The <code>${1:-}</code> syntax means:</p>
<p><em>“If the argument is missing, default to an empty string instead of crashing.”</em></p>
<p>This works hand-in-hand with <code>set -u</code> , we control the failure explicitly instead of letting Bash explode unexpectedly.</p>
<h4 id="heading-4-defining-input-and-output-files">4. Defining Input and Output Files</h4>
<pre><code class="language-yaml">TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"
</code></pre>
<p>Here we define two files:</p>
<ul>
<li><p><strong>Template file (</strong><code>env_ci.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains placeholder values like <code>&lt;&lt;BASE_URL&gt;&gt;</code></p>
</li>
<li><p>Safe to commit to Git</p>
</li>
</ul>
</li>
<li><p><strong>Generated file (</strong><code>env_ci.g.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Must be ignored by Git (<code>.gitignore</code>)</p>
</li>
</ul>
</li>
</ul>
<p>At the heart of this approach are two Dart files with very different responsibilities. They may look similar, but they play completely different roles in the system.</p>
<h4 id="heading-envcidart"><code>env.ci.dart</code>:</h4>
<pre><code class="language-java">// lib/core/env/env_ci.dart

class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p>This file is <strong>safe</strong>, <strong>static</strong>, and <strong>version-controlled</strong>. It contains placeholders, not real values.</p>
<p>Some of its key characteristics are:</p>
<ul>
<li><p>Contains no real secrets</p>
</li>
<li><p>Uses obvious placeholders (<code>&lt;&lt;BASE_URL&gt;&gt;</code>, etc.)</p>
</li>
<li><p>Safe to commit to Git</p>
</li>
<li><p>Reviewed like normal source code</p>
</li>
<li><p>Serves as the single source of truth for required config fields</p>
</li>
</ul>
<p>Think of this file as a contract:</p>
<p><em>“These are the configuration values the app expects at runtime.”</em></p>
<h4 id="heading-envcigdart"><code>env.ci.g.dart</code>:</h4>
<p>This file is created at <strong>build time</strong> by <code>generate_config.sh</code>. After substitution, it looks like this:</p>
<pre><code class="language-java">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT

class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Key characteristics:</p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Generated dynamically in CI</p>
</li>
<li><p>Differs per environment (dev / staging / production)</p>
</li>
<li><p>Must <strong>never</strong> be committed to source control</p>
</li>
</ul>
<p>This file exists only on a developer’s machine (if generated locally), inside the CI runner during a build. Once the job finishes, it disappears.</p>
<h4 id="heading-gitignore"><code>.gitignore</code>:</h4>
<p>To guarantee the generated file never leaks, it must be ignored:</p>
<h4 id="heading-why-this-separation-is-critical">Why This Separation Is Critical</h4>
<p>This design solves several hard problems at once.</p>
<p><strong>Security:</strong></p>
<ul>
<li><p>Secrets live <strong>only</strong> in GitHub Actions secrets</p>
</li>
<li><p>They never appear in the repository</p>
</li>
<li><p>They never appear in PRs</p>
</li>
<li><p>They never appear in Git history</p>
</li>
</ul>
<p><strong>Environment Isolation:</strong></p>
<p>Each environment gets its own generated config:</p>
<ul>
<li><p><code>develop</code>: dev API</p>
</li>
<li><p><code>staging</code>: staging API</p>
</li>
<li><p><code>production</code>: production API</p>
</li>
</ul>
<p>The same codebase behaves differently <strong>without branching logic in Dart</strong>.</p>
<p><strong>Deterministic Builds:</strong></p>
<p>Every build is fully reproducible, fully automated, and explicit about which environment it targets.</p>
<p>There are no “it worked locally” scenarios.</p>
<h4 id="heading-5-validating-required-arguments">5. Validating Required Arguments</h4>
<pre><code class="language-java">if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi
</code></pre>
<p>This block enforces correct usage.</p>
<ul>
<li><p><code>-z</code> checks whether a variable is empty</p>
</li>
<li><p>If any required argument is missing:</p>
<ul>
<li><p>A helpful usage message is printed</p>
</li>
<li><p>The script exits with a non-zero status code</p>
</li>
</ul>
</li>
<li><p><code>0</code>: success</p>
</li>
<li><p><code>1+</code>: failure</p>
</li>
<li><p><code>2</code> conventionally means incorrect usage</p>
</li>
</ul>
<p>In CI, this immediately fails the job and prevents an invalid build.</p>
<h4 id="heading-6-injecting-environment-values">6. Injecting Environment Values</h4>
<pre><code class="language-java">sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"
</code></pre>
<p>This is the heart of the script.</p>
<p>What’s happening here:</p>
<ol>
<li><p><code>sed</code> performs <strong>stream editing</strong>: it reads text, transforms it, and outputs the result</p>
</li>
<li><p>Each <code>-e</code> flag defines a replacement rule:</p>
<ul>
<li><p>Replace <code>&lt;&lt;BASE_URL&gt;&gt;</code> with the actual API URL</p>
</li>
<li><p>Replace <code>&lt;&lt;ENCRYPTION_KEY&gt;&gt;</code> with the real key</p>
</li>
<li><p>Replace <code>&lt;&lt;ENV_NAME&gt;&gt;</code> with the environment label</p>
</li>
</ul>
</li>
<li><p>The transformed output is written to <code>env_ci.g.dart</code></p>
</li>
</ol>
<p>This entire operation happens <strong>at build time</strong>:</p>
<ul>
<li><p>No secrets are committed</p>
</li>
<li><p>No secrets are logged</p>
</li>
<li><p>No secrets persist beyond the CI run</p>
</li>
</ul>
<h4 id="heading-7-success-feedback">7. Success Feedback</h4>
<pre><code class="language-java">echo "Generated config for $ENV_NAME"
</code></pre>
<p>This line provides a clear success signal in CI logs.</p>
<p>It answers three important questions instantly:</p>
<ul>
<li><p>Did the script run?</p>
</li>
<li><p>Did it finish successfully?</p>
</li>
<li><p>Which environment was generated?</p>
</li>
</ul>
<p>In long CI logs, these small confirmations matter.</p>
<p>Alright, now let's move on to the second script.</p>
<h3 id="heading-script-2-qualitygatesh">Script #2: <code>quality_gate.sh</code></h3>
<p>This script defines what <em>“good code”</em> means for your team.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

echo "Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "Quality checks passed"
</code></pre>
<p>Lets break down this script bit by bit.</p>
<h4 id="heading-1-start-amp-end-log-markers">1. Start &amp; End Log Markers</h4>
<pre><code class="language-yaml">echo "Running quality checks"
...
echo "Quality checks passed"
</code></pre>
<p>These two lines act as <strong>visual boundaries</strong> in CI logs.</p>
<p>In large pipelines (especially when Android and iOS jobs run in parallel), logs can be very noisy. Clear markers:</p>
<ul>
<li><p>Help developers quickly find the quality phase</p>
</li>
<li><p>Make debugging faster</p>
</li>
<li><p>Confirm that the script completed successfully</p>
</li>
</ul>
<p>The final success message only prints if <strong>everything above it passed</strong>, because <code>set -e</code> would have terminated the script earlier on failure.</p>
<p>So this line effectively means: All quality gates passed. Safe to proceed.</p>
<h4 id="heading-2-running-the-test-suite">2. Running the Test Suite</h4>
<pre><code class="language-yaml">flutter test --no-pub --coverage
</code></pre>
<p>This line executes your entire Flutter test suite.</p>
<p>Let’s break it down carefully.</p>
<p>1. <code>flutter test</code></p>
<p>This runs unit tests, widget tests, and any test under the <code>test/</code> directory. If <strong>any test fails</strong>, the command exits with a non-zero status code.</p>
<p>Because we enabled <code>set -e</code> earlier, that immediately stops the script and fails the CI job.</p>
<p>2. <code>--coverage</code></p>
<p>This flag generates a coverage report at:</p>
<pre><code class="language-yaml">coverage/lcov.info
</code></pre>
<p>This file can later be uploaded to Codecov, used to enforce minimum coverage thresholds, and tracked over time for quality improvement.</p>
<p>Even if you’re not enforcing coverage yet, generating it now future-proofs your pipeline.</p>
<h4 id="heading-3-optional-code-metrics">3. Optional Code Metrics</h4>
<pre><code class="language-yaml">if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi
</code></pre>
<p>This block is intentionally designed to be optional and non-blocking.</p>
<p><strong>Step 1 – Check If the Tool Exists:</strong></p>
<pre><code class="language-yaml">command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>This checks whether <code>dart_code_metrics</code> is installed.</p>
<ul>
<li><p>If installed, proceed</p>
</li>
<li><p>If not installed, skip silently</p>
</li>
</ul>
<p>The redirection:</p>
<ul>
<li><p><code>&gt;/dev/null</code> hides normal output</p>
</li>
<li><p><code>2&gt;&amp;1</code> hides errors</p>
</li>
</ul>
<p>This makes the script portable:</p>
<ul>
<li><p>Developers without the tool can still run the script</p>
</li>
<li><p>CI can enforce it if configured</p>
</li>
</ul>
<p><strong>Step 2 – Run Metrics (Soft Enforcement):</strong></p>
<pre><code class="language-yaml">dart_code_metrics analyze lib --reporter=console || true
</code></pre>
<p>This analyzes the <code>lib/</code> directory and prints results in the console.</p>
<p>The important part is:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>Because we enabled <code>set -e</code>, any failing command would normally stop the script.</p>
<p>Adding <code>|| true</code> overrides that behavior:</p>
<ul>
<li><p>If metrics report issues,</p>
</li>
<li><p>The script continues,</p>
</li>
<li><p>CI does not fail.</p>
</li>
</ul>
<p>Why design it this way? Because metrics are often gradual improvements, technical debt indicators, or advisory rather than blocking.</p>
<p>You can later remove <code>|| true</code> to make metrics mandatory.</p>
<h4 id="heading-4-final-success-message"><strong>4. Final Success Message</strong></h4>
<pre><code class="language-yaml">echo "✅ Quality checks passed"
</code></pre>
<p>This line only executes if formatting passed, static analysis passed, and tests passed.</p>
<p>If you see this in CI logs, it means the branch has successfully cleared the quality gate. It’s your automated approval before deployment steps begin.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>With this in place, every branch must satisfy:</p>
<ul>
<li><p>Clean formatting</p>
</li>
<li><p>No analyzer errors</p>
</li>
<li><p>Passing tests</p>
</li>
<li><p>(Optional) Healthy metrics</p>
</li>
</ul>
<p>That’s how you move from <strong>“We try to maintain quality”</strong> to <strong>“Quality is enforced automatically.”</strong></p>
<p>Alright, on to the third script.</p>
<h3 id="heading-script-3-uploadsymbolssh-sentry"><strong>Script #3:</strong> <code>upload_symbols.sh</code> <strong>(Sentry)</strong></h3>
<p>This script is responsible for uploading <strong>obfuscation debug symbols</strong> to Sentry so production crashes remain readable.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true

sentry-cli upload-dif build/symbols || true

sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p>Let's go through it step by step.</p>
<h4 id="heading-1-reading-the-release-identifier">1. Reading the Release Identifier</h4>
<pre><code class="language-yaml">RELEASE=${1:-}
</code></pre>
<p>This reads the first positional argument passed to the script.</p>
<p>When you call the script in CI, it typically looks like:</p>
<pre><code class="language-yaml">./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>So <code>$1</code> becomes the short Git commit SHA.</p>
<p>Using <code>${1:-}</code> ensures:</p>
<ul>
<li><p>If no argument is passed, the variable becomes an empty string</p>
</li>
<li><p>The script does not crash due to <code>set -u</code></p>
</li>
</ul>
<p>This release value ties the uploaded symbols, deployed build, and crash reports all to the exact same commit. This linkage is critical for production debugging.</p>
<h4 id="heading-2-validating-the-release-argument">2. Validating the Release Argument</h4>
<pre><code class="language-yaml">[ -z "$RELEASE" ] &amp;&amp; exit 2
</code></pre>
<p>This is a compact validation check.</p>
<ul>
<li><p><code>-z</code> checks whether the string is empty</p>
</li>
<li><p>If it is empty → exit with status code 2</p>
</li>
</ul>
<p>Conventionally:</p>
<ul>
<li><p><code>0</code> = success</p>
</li>
<li><p><code>1+</code> = failure</p>
</li>
<li><p><code>2</code> = incorrect usage</p>
</li>
</ul>
<p>This prevents symbol uploads from running without a release identifier, which would break traceability in Sentry.</p>
<h4 id="heading-3-checking-if-sentry-cli-exists">3. Checking If <code>sentry-cli</code> Exists</h4>
<pre><code class="language-yaml">if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi
</code></pre>
<p>This block checks whether the <code>sentry-cli</code> tool is available in the environment.</p>
<p>What’s happening:</p>
<ul>
<li><p><code>command -v sentry-cli</code> checks if it exists</p>
</li>
<li><p><code>&gt;/dev/null 2&gt;&amp;1</code> suppresses all output</p>
</li>
<li><p><code>!</code> negates the condition</p>
</li>
</ul>
<p>So this reads as: <em>"If</em> <code>sentry-cli</code> <em>is NOT installed, exit successfully."</em></p>
<p>Why exit with <code>0</code> instead of failing?</p>
<p>Because not every environment needs symbol uploads. Also, dev builds may not install Sentry, and you don’t want CI to fail just because Sentry isn’t configured.</p>
<p>This makes symbol uploading <strong>environment-aware</strong> and <strong>optional</strong>.</p>
<p>Production environments can install <code>sentry-cli</code>, while dev environments skip it cleanly.</p>
<h4 id="heading-4-creating-a-new-release-in-sentry">4. Creating a New Release in Sentry</h4>
<pre><code class="language-yaml">sentry-cli releases new "$RELEASE" || true
</code></pre>
<p>This tells Sentry: “A new release exists with this version identifier.”</p>
<p>Even if the release already exists, the script continues because of:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>This prevents the build from failing if:</p>
<ul>
<li><p>The release was already created</p>
</li>
<li><p>The command returns a non-critical error</p>
</li>
</ul>
<p>The goal is resilience, not strict enforcement.</p>
<h4 id="heading-5-uploading-debug-information-files-difs">5. Uploading Debug Information Files (DIFs)</h4>
<pre><code class="language-yaml">sentry-cli upload-dif build/symbols || true
</code></pre>
<p>This is the core step.</p>
<p><code>build/symbols</code> is generated when you build Flutter with:</p>
<pre><code class="language-yaml">--obfuscate --split-debug-info=build/symbols
</code></pre>
<p>When you obfuscate Flutter builds:</p>
<ul>
<li><p>Method names are renamed</p>
</li>
<li><p>Stack traces become unreadable</p>
</li>
</ul>
<p>The symbol files allow Sentry to reverse-map obfuscated stack traces and show readable crash reports.</p>
<p>Without this step, production crashes look like:</p>
<pre><code class="language-yaml">a.b.c.d (Unknown Source)
</code></pre>
<p>With this step, you get:</p>
<pre><code class="language-yaml">AuthRepository.login()
</code></pre>
<p>Again, <code>|| true</code> ensures the build doesn’t fail if:</p>
<ul>
<li><p>The directory doesn’t exist</p>
</li>
<li><p>No symbols were generated</p>
</li>
<li><p>Upload encounters a transient issue</p>
</li>
</ul>
<p>Symbol uploads should not block deployment.</p>
<h4 id="heading-6-finalizing-the-release">6. Finalizing the Release</h4>
<pre><code class="language-yaml">sentry-cli releases finalize "$RELEASE" || true
</code></pre>
<p>This marks the release as complete in Sentry.</p>
<p>Finalizing signals:</p>
<ul>
<li><p>The release is deployed</p>
</li>
<li><p>It can begin aggregating crash reports</p>
</li>
<li><p>It’s ready for production monitoring</p>
</li>
</ul>
<p>Like the previous steps, this is soft-failed with <code>|| true</code> to keep CI robust.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>When everything is configured correctly:</p>
<ol>
<li><p>Production build is obfuscated</p>
</li>
<li><p>Debug symbols are generated</p>
</li>
<li><p>Symbols are uploaded to Sentry</p>
</li>
<li><p>Crashes map back to real source code</p>
</li>
<li><p>Release version matches commit SHA</p>
</li>
</ol>
<p>That’s production-grade crash observability.</p>
<p>Now that we've gone through the three helper scripts we've created to optimize and enhance this process, lets now dive into the three workflow .yaml files we're going to create.</p>
<h2 id="heading-workflow-1-prchecksyml">Workflow #1: <code>PR_CHECKS.YML</code></h2>
<p>This workflow will be designed to help ensure a certain standard is met once a PR is raised into a certain common or base branch. This will ensure that all quality checks in the incoming code pass before allowing any merge into the base branch.</p>
<p>This is basically a gate that verifies the quality of the code that's about to be merged into the base branch. If your pipeline allows unverified code into your base branch, then your CI becomes decorative, not protective.</p>
<p>Lets break down what's actually needed during every PR Check.</p>
<h3 id="heading-1-dependency-integrity">1. Dependency Integrity</h3>
<p>For Flutter apps, where we manage dependencies with the <strong>pub get</strong> command, it's important to make sure that the integrity of all dependencies are confirmed – up to date as well as compatible.</p>
<p>Every PR should begin with:</p>
<pre><code class="language-yaml">flutter pub get
</code></pre>
<p>This ensures:</p>
<ul>
<li><p><code>pubspec.yaml</code> is valid</p>
</li>
<li><p>Dependency constraints are consistent</p>
</li>
<li><p>Lockfiles are not broken</p>
</li>
<li><p>The project is buildable in a clean environment</p>
</li>
</ul>
<p>If this fails, the branch is not deployable.</p>
<h3 id="heading-2-static-analysis">2. Static Analysis</h3>
<p>This ensures code quality and architecture integrity. Static analysis helps prevent common issues like forgotten await, dead code, null safety violations, async misuse, and so on.</p>
<p>Most production bugs aren't business logic errors – they're structural carelessness. Static analysis helps enforce consistency automatically, so code reviews focus on intent, not linting.</p>
<pre><code class="language-yaml">flutter analyze --fatal-infos --fatal-warnings
</code></pre>
<h3 id="heading-3-formatting">3. Formatting</h3>
<p>This command ensures that your code is properly formatted based on your organization's coding standard and policies.</p>
<pre><code class="language-yaml">dart format --output=none --set-exit-if-changed .
</code></pre>
<h3 id="heading-4-tests">4. Tests</h3>
<p>This runs the unit, widget and business logic tests to ensure quality and avoid regression leaks, silent behavior changes and feature drift.</p>
<pre><code class="language-yaml">flutter test --coverage
</code></pre>
<h3 id="heading-5-test-coverage-enforcement">5. Test Coverage Enforcement</h3>
<p>Ideally, running tests is not enough. Your workflow should also enforce a minimum threshold:</p>
<pre><code class="language-yaml">if [ \((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//') -lt 70 ]; then
  echo "Coverage too low"
  exit 1
fi
</code></pre>
<p>The command above ensures that a minimum test coverage of 70% is met, with this quality becomes measurable.</p>
<p>The five commands above must be checked (at least) for a <strong>quality gate</strong> to guarantee code quality, security, and integrity.</p>
<p>Now here is the full <strong>pr_checks.yml</strong> file:</p>
<pre><code class="language-yaml">name: PR Quality Gate

on:
  pull_request:
    branches: develop
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  pr-checks:
    name: Run quality checks on this pull request
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Java
        uses: actions/setup-java@v1
        with:
          java-version: "12.x"

      - name: Setup Flutter
        uses: subosito/flutter-action@v1
        with:
          channel: "stable"

      - name: Install dependencies
        run: flutter pub get

      - name: Run quality checks
        run: ./scripts/quality_checks.sh

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "PR Quality Checks PASSED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "PR Quality Checks FAILED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Please fix the issues before requesting review 🔧"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"
</code></pre>
<p>Every time a developer opens (or updates) a pull request targeting the <code>develop</code> branch, this workflow kicks in automatically. Think of it as a bouncer at the door: no code gets through without passing inspection first.</p>
<h3 id="heading-what-triggers-it">What Triggers it?</h3>
<p>The workflow fires on four events: when a PR is <code>opened</code>, <code>synchronized</code> (new commits pushed), <code>reopened</code>, or marked <code>ready_for_review</code>. So drafts won't trigger it – only PRs that are actually ready to be looked at.</p>
<h3 id="heading-what-does-it-actually-do">What Does it Actually Do?</h3>
<p>It spins up a fresh Ubuntu machine and runs five steps in sequence:</p>
<ol>
<li><p><strong>Checkout</strong>: pulls down the branch's code</p>
</li>
<li><p><strong>Setup Java 12</strong>: installs the JDK (likely a dependency for some tooling or build process)</p>
</li>
<li><p><strong>Setup Flutter (stable channel)</strong>: this is a Flutter project, so it grabs the stable Flutter SDK</p>
</li>
<li><p><strong>Install dependencies</strong>: runs <code>flutter pub get</code> to pull all Dart/Flutter packages</p>
</li>
<li><p><strong>Run quality checks</strong>: executes the helper shell script (<code>./scripts/quality_checks.sh</code>) that we created which runs linting, tests, formatting checks, or all of the above</p>
</li>
</ol>
<h3 id="heading-the-notification-layer">The Notification Layer</h3>
<p>After the checks run, the workflow reports the verdict and it's context-aware:</p>
<ul>
<li><p><strong>If everything passes</strong>, it logs a success message with the PR URL, branch info, and the person who opened it</p>
</li>
<li><p><strong>If something fails</strong>, it logs a failure message and nudges the author to fix issues before requesting a review</p>
</li>
</ul>
<p>Both outcomes tag <code>@foluwaseyi-dev</code> and <code>@olabodegbolu</code> – the two team members responsible for staying in the loop.</p>
<p>This workflow enforces a "fix it before you merge it" culture. No one can merge broken code into <code>develop</code> without the team knowing about it.</p>
<h2 id="heading-workflow-2-androidyml">Workflow #2: Android.yml</h2>
<p>It's a better practice to split your workflows based on platform. This helps you properly manage the instructions regarding each platform. This is the reason behind keeping the Android workflow separate.</p>
<p>Unlike <code>PR _Checks</code>, this workflow presumes that all checks for quality and standards have been done and the code that runs this workflow already meets the required standards.</p>
<p>Based on our predefined use case, let's create a workflow to handle test deployments when merged to develop or staging, and production level activities when merged to production.</p>
<pre><code class="language-yaml">name: Android Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  android:
    runs-on: ubuntu-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      # Dev uses hardcoded values no secrets needed
      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      # Staging and production inject real secrets
      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      # Keystore is only needed for signed builds (staging &amp; production)
      - name: Restore Keystore
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks

      # Production builds are obfuscated + split debug info for Play Store
      - name: Build artifact
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      # Dev and staging go to Firebase App Distribution for internal testing
      - name: Upload to Firebase App Distribution
        if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
          FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
          FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
        run: |
          firebase appdistribution:distribute \
            build/app/outputs/bundle/release/app-release.aab \
            --app "$FIREBASE_ANDROID_APP_ID" \
            --groups "$FIREBASE_GROUPS" \
            --token "$FIREBASE_TOKEN"

      # Only production goes to the Play Store
      - name: Upload to Play Store
        if: steps.env.outputs.ENV == 'production'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.your.package
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: production

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "Android Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "Android Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying"
</code></pre>
<p>This workflow ensures that whenever code lands on the <strong>develop, staging or production</strong> branch, this action is triggered on a fresh Ubuntu machine.</p>
<p>This is triggered by a simple push to any of the tracked branches, no manual intervention needed.</p>
<p>Let's walk through it piece by piece.</p>
<h3 id="heading-1-the-setup-phase">1. The Setup Phase</h3>
<p>Before any Flutter-specific work happens, the workflow lays the foundation:</p>
<ol>
<li><p><strong>Checkout</strong>: grabs the latest code from the branch that triggered the run (using the more modern <code>actions/checkout@v3</code>).</p>
</li>
<li><p><strong>Java 11 via Temurin</strong>: this is an upgrade from the first workflow we created. Instead of a generic <code>setup-java@v1</code>, this uses the <code>temurin</code> distribution which is the Eclipse's open-source JDK build. It's the current industry standard for Android toolchains.</p>
</li>
<li><p><strong>Flutter (stable)</strong>: this pulls the stable Flutter SDK, version pinned via an environment variable (<code>FLUTTER_VERSION: 'stable'</code>) defined at the job level.</p>
</li>
<li><p><strong>Install dependencies</strong>: this ensures we run <code>flutter pub get</code> to pull all packages</p>
</li>
</ol>
<h3 id="heading-2-environment-detection">2. Environment Detection</h3>
<p>This is where it gets interesting. This workflow also checks and determines the environment which will help us define the next set of instructions to run.</p>
<p>This command reads the branch name from <strong>GITHUB REF</strong> and maps it to its environment label which we already created in one of our helper scripts.</p>
<ul>
<li><p>develop → ENV=dev</p>
</li>
<li><p>staging → ENV=staging</p>
</li>
<li><p>production → ENV=production</p>
</li>
</ul>
<p>It strips the branch name from the full ref path using <code>\({GITHUB_REF##*/}</code>, then writes both the branch name and the resolved <code>ENV</code> value to <code>\)GITHUB_OUTPUT</code>, making them available as named outputs (<code>steps.env.outputs.ENV</code>) for every subsequent step.</p>
<p>This means the rest of the pipeline can branch its behaviour based on which environment it's building for, different API keys, different signing configs, different targets – whatever the app needs.</p>
<h3 id="heading-3-config-injection">3. Config Injection</h3>
<p>With the environment resolved, the next step is injecting the right configuration into the app. This is where the <code>generate_config.sh</code> script we built earlier gets called directly from the workflow.</p>
<p>For the <code>dev</code> environment, hardcoded placeholder values are used. No real secrets are needed, since this build is only meant for internal developer testing:</p>
<pre><code class="language-yaml">- name: Generate config (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
</code></pre>
<p>For staging and production, however, real secrets are pulled from GitHub Actions secrets and passed directly into the script:</p>
<pre><code class="language-yaml">- name: Generate config (staging/production)
  if: steps.env.outputs.ENV != 'dev'
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
      ./scripts/generate_config.sh staging \
        "${{ secrets.STAGING_BASE_URL }}" \
        "${{ secrets.STAGING_API_KEY }}"
    else
      ./scripts/generate_config.sh production \
        "${{ secrets.PROD_BASE_URL }}" \
        "${{ secrets.PROD_API_KEY }}"
    fi
</code></pre>
<p>Notice that these two steps use an <code>if</code> condition to make them mutually exclusive. Only one will ever run per job. This keeps the pipeline clean: no complicated branching logic inside the script itself, just a clear decision at the workflow level.</p>
<h3 id="heading-4-keystore-restoration">4. Keystore Restoration</h3>
<p>Android requires signed builds for distribution. The signing keystore file cannot be committed to the repository for obvious security reasons, so it's stored as a Base64-encoded GitHub secret and decoded at build time.</p>
<pre><code class="language-yaml">- name: Restore Keystore
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks
</code></pre>
<p>This step is skipped entirely for the <code>dev</code> environment because dev builds are unsigned debug artifacts meant purely for internal testing on Firebase App Distribution. Only staging and production builds need to be properly signed.</p>
<p>To encode your keystore file as a Base64 string for storing in GitHub secrets, you have to run this locally:</p>
<pre><code class="language-yaml">base64 -i upload-keystore.jks | pbcopy
</code></pre>
<p>This copies the encoded string to your clipboard, which you can then paste directly into your GitHub repository secrets.</p>
<h3 id="heading-5-building-the-artifact">5. Building the Artifact</h3>
<p>With the environment configured and the keystore in place, the workflow builds the app bundle:</p>
<pre><code class="language-yaml">- name: Build artifact
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
      flutter build appbundle --release \
        --obfuscate \
        --split-debug-info=build/symbols
    else
      flutter build appbundle --release
    fi
</code></pre>
<p>There's a deliberate difference between how production and non-production builds are compiled.</p>
<p>For production:</p>
<ul>
<li><p><code>--obfuscate</code> renames method and class names in the compiled output, making it significantly harder to reverse engineer the app</p>
</li>
<li><p><code>--split-debug-info=build/symbols</code> extracts the debug symbols into a separate directory at <code>build/symbols</code></p>
</li>
</ul>
<p>These symbols are what <code>upload_symbols.sh</code> later ships to Sentry, so obfuscated crash reports remain readable in your monitoring dashboard.</p>
<p>For dev and staging, neither flag is used. This keeps build times faster and makes local debugging easier since stack traces remain human-readable.</p>
<h3 id="heading-6-distributing-to-firebase-app-distribution">6. Distributing to Firebase App Distribution</h3>
<p>Once the app bundle is built, dev and staging builds are uploaded to Firebase App Distribution so testers can install them immediately:</p>
<pre><code class="language-yaml">- name: Upload to Firebase App Distribution
  if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
  env:
    FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
    FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
    FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
  run: |
    firebase appdistribution:distribute \
      build/app/outputs/bundle/release/app-release.aab \
      --app "$FIREBASE_ANDROID_APP_ID" \
      --groups "$FIREBASE_GROUPS" \
      --token "$FIREBASE_TOKEN"
</code></pre>
<p>Three secrets power this step:</p>
<ul>
<li><p><code>FIREBASE_TOKEN</code>: the authentication token generated from <code>firebase login:ci</code></p>
</li>
<li><p><code>FIREBASE_ANDROID_APP_ID</code>: the app identifier from the Firebase console</p>
</li>
<li><p><code>FIREBASE_GROUPS</code>: the tester group(s) that should receive the build notification</p>
</li>
</ul>
<p>Once this step completes, every tester in the specified groups receives an email with a direct download link. No one needs to manually share an APK file over Slack or email.</p>
<h3 id="heading-7-deploying-to-the-play-store">7. Deploying to the Play Store</h3>
<p>Production builds skip Firebase entirely and goes straight to the Google Play Store:</p>
<pre><code class="language-yaml">- name: Upload to Play Store
  if: steps.env.outputs.ENV == 'production'
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.your.package
    releaseFiles: build/app/outputs/bundle/release/app-release.aab
    track: production
</code></pre>
<p>This uses the <code>r0adkll/upload-google-play</code> GitHub Action, which handles the Google Play API interaction under the hood. The only requirements are:</p>
<ul>
<li><p>A Google Play service account with the correct permissions, stored as a JSON secret</p>
</li>
<li><p>The correct package name matching what is registered in your Play Console</p>
</li>
<li><p>The <code>track</code> set to <code>production</code> (you can also use <code>internal</code>, <code>alpha</code>, or <code>beta</code> depending on your release strategy)</p>
</li>
</ul>
<p>Replace <code>com.your.package</code> with your actual application ID (the same one defined in your <code>build.gradle</code> file).</p>
<h3 id="heading-8-the-notification-layer">8. The Notification Layer</h3>
<p>Just like the PR checks workflow, this workflow reports its outcome clearly:</p>
<pre><code class="language-yaml">- name: Notify Team (Success)
  if: success()
  run: |
    echo "Android Build &amp; Release PASSED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"

- name: Notify Team (Failure)
  if: failure()
  run: |
    echo "Android Build &amp; Release FAILED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"
    echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>The success notification includes the environment, branch, actor, and shares everything needed to trace exactly what was deployed and who triggered it.</p>
<p>The failure notification includes the same context, with a clear call to action.</p>
<h2 id="heading-workflow-3-iosyml">Workflow #3: iOS.yml</h2>
<p>iOS CI/CD is more complex than Android by nature. This is because Apple's signing requirements involve certificates, provisioning profiles, and entitlements that all need to be in the right place before Xcode will produce a valid archive.</p>
<p>Fastlane helps us handles all of that complexity, and the workflow simply calls into it.</p>
<p>Here is the full <code>ios.yml</code>:</p>
<pre><code class="language-yaml">name: iOS Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  ios:
    runs-on: macos-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      - name: Install Fastlane
        run: |
          cd ios
          gem install bundler
          bundle install

      - name: Import signing certificate
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
          security create-keychain -p "" build.keychain
          security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
          security list-keychains -s build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "" build.keychain
          security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

      - name: Install provisioning profile
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/

      - name: Build iOS (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: flutter build ios --release --no-codesign

      - name: Build &amp; distribute to TestFlight (staging)
        if: steps.env.outputs.ENV == 'staging'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane beta

      - name: Build &amp; release to App Store (production)
        if: steps.env.outputs.ENV == 'production'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane release

      - name: Upload Sentry symbols (production only)
        if: steps.env.outputs.ENV == 'production'
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
        run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "iOS Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "iOS Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>Let's walk through what is different about this workflow compared to that of android.</p>
<h3 id="heading-1-macos-runner">1. MacOS Runner</h3>
<pre><code class="language-yaml">runs-on: macos-latest
</code></pre>
<p>This is the major difference.</p>
<p>iOS builds require Xcode, which only runs on macOS. GitHub Actions provides hosted macOS runners, but they are significantly more expensive in terms of compute minutes than Ubuntu runners. Just keep that in mind when thinking about build frequency.</p>
<p>No Java setup is needed here. Flutter on iOS compiles through Xcode directly, so the toolchain requirements are different.</p>
<h3 id="heading-2-installing-fastlane">2. Installing Fastlane</h3>
<pre><code class="language-yaml">- name: Install Fastlane
  run: |
    cd ios
    gem install bundler
    bundle install
</code></pre>
<p>Fastlane is a Ruby-based automation tool that handles certificate management, building, and uploading to TestFlight and the App Store.</p>
<p>This step navigates into the <code>ios/</code> directory and installs Fastlane along with all its dependencies as defined in the project's <code>Gemfile</code>.</p>
<p>Your <code>ios/Gemfile</code> should look something like this:</p>
<pre><code class="language-ruby">source "https://rubygems.org"

gem "fastlane"
</code></pre>
<p>And your <code>ios/fastlane/Fastfile</code> should define at minimum two lanes: one for staging (TestFlight) and one for production (App Store):</p>
<pre><code class="language-ruby">default_platform(:ios)

platform :ios do
  lane :beta do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end

  lane :release do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_app_store(force: true, skip_screenshots: true, skip_metadata: true)
  end
end
</code></pre>
<h3 id="heading-3-certificate-and-provisioning-profile-setup">3. Certificate and Provisioning Profile Setup</h3>
<p>This is the step that trips most teams up the first time. Apple's code signing requires two things to be present on the machine:</p>
<ol>
<li><p>The signing certificate (a <code>.p12</code> file)</p>
</li>
<li><p>The provisioning profile</p>
</li>
</ol>
<p>Both are stored as Base64-encoded GitHub secrets and restored at build time.</p>
<pre><code class="language-yaml">- name: Import signing certificate
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
    security create-keychain -p "" build.keychain
    security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
    security list-keychains -s build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "" build.keychain
    security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
</code></pre>
<p>Breaking this down step by step:</p>
<ul>
<li><p>Decodes the Base64 certificate and write it to <code>cert.p12</code></p>
</li>
<li><p>Creates a temporary keychain called <code>build.keychain</code> with an empty password</p>
</li>
<li><p>Imports the certificate into that keychain, granting codesign access</p>
</li>
<li><p>Sets it as the default keychain so Xcode finds it automatically</p>
</li>
<li><p>Unlocks the keychain so it can be used non-interactively</p>
</li>
<li><p>Sets partition list to allow access without repeated prompts</p>
</li>
</ul>
<p>The provisioning profile step is simpler:</p>
<pre><code class="language-yaml">- name: Install provisioning profile
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
</code></pre>
<p>It decodes the profile and copies it into the exact directory where Xcode expects to find provisioning profiles on any macOS system.</p>
<p>To encode your certificate and profile locally, you can run these:</p>
<pre><code class="language-bash">base64 -i Certificates.p12 | pbcopy   # for the certificate
base64 -i YourApp.mobileprovision | pbcopy   # for the provisioning profile
</code></pre>
<h3 id="heading-4-building-for-each-environment">4. Building for Each Environment</h3>
<p>Dev builds skip signing entirely. They're built without code signing just to verify the project compiles correctly on a clean machine:</p>
<pre><code class="language-yaml">- name: Build iOS (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: flutter build ios --release --no-codesign
</code></pre>
<p>Staging builds go through Fastlane's <code>beta</code> lane, which builds and uploads to TestFlight. Production builds go through Fastlane's <code>release</code> lane, which submits directly to App Store Connect.</p>
<p>Both staging and production steps consume the same three App Store Connect API key secrets: the key ID, the issuer ID, and the key content itself.</p>
<p>Fastlane uses these to authenticate with Apple's API without requiring a manual Apple ID login.</p>
<h3 id="heading-5-sentry-symbol-upload">5. Sentry Symbol Upload</h3>
<p>On production iOS builds, the <code>upload_symbols.sh</code> script runs after the build completes, passing the current short commit SHA as the release identifier:</p>
<pre><code class="language-yaml">- name: Upload Sentry symbols (production only)
  if: steps.env.outputs.ENV == 'production'
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
    SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
  run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>This is the same script explained earlier in the helper scripts section. It creates a Sentry release, uploads the debug information files, and finalizes the release. Any production crash from this point forward will map back to real, readable source code in your Sentry dashboard.</p>
<h2 id="heading-secrets-and-configuration-reference">Secrets and Configuration Reference</h2>
<p>For this entire pipeline to work, you need to configure the following secrets in your GitHub repository. Go to <strong>Settings → Secrets and variables → Actions → New repository secret</strong> to add each one.</p>
<p><strong>Shared (used across environments):</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code> on your local machine</td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from your Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names in Firebase</td>
</tr>
<tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from your Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p><strong>Staging:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Your staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Your staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Production:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Your production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Your production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Android:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ANDROID_KEYSTORE_BASE64</code></td>
<td>Base64-encoded <code>.jks</code> keystore file</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON content of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>iOS:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>IOS_CERTIFICATE_BASE64</code></td>
<td>Base64-encoded <code>.p12</code> signing certificate</td>
</tr>
<tr>
<td><code>IOS_CERTIFICATE_PASSWORD</code></td>
<td>Password protecting the <code>.p12</code> file</td>
</tr>
<tr>
<td><code>IOS_PROVISIONING_PROFILE_BASE64</code></td>
<td>Base64-encoded <code>.mobileprovision</code> file</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_ID</code></td>
<td>Key ID from App Store Connect → Users &amp; Access → Keys</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_ISSUER_ID</code></td>
<td>Issuer ID from the same App Store Connect page</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_CONTENT</code></td>
<td>The full content of the downloaded <code>.p8</code> key file</td>
</tr>
</tbody></table>
<p>None of these values should ever appear in your codebase. If any secret is accidentally committed, rotate it immediately.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With all three workflows in place, here is exactly what happens from the moment a developer opens a pull request to the moment a user receives an update:</p>
<h3 id="heading-1-developer-opens-a-pr-into-develop">1. Developer Opens a PR into <code>develop</code></h3>
<p>The <code>pr_checks.yml</code> workflow fires. It runs formatting checks, static analysis, and the full test suite. If anything fails, the PR cannot be merged and the team is notified immediately. The developer fixes the issues and pushes again, which triggers a fresh run.</p>
<h3 id="heading-2-pr-is-approved-and-merged-into-develop">2. PR is Approved and Merged into <code>develop</code></h3>
<p>The <code>android.yml</code> and <code>ios.yml</code> workflows both fire on the push event. They detect the environment as <code>dev</code>, inject placeholder config, build unsigned artifacts, and upload them to Firebase App Distribution. Testers receive an email and can install the build on their devices within minutes – no one shared a file manually.</p>
<h3 id="heading-3-develop-is-merged-into-staging">3. <code>develop</code> is Merged into <code>staging</code></h3>
<p>Both platform workflows fire again. This time the environment resolves to <code>staging</code>. Real secrets are injected, builds are properly signed, and the artifacts go to Firebase App Distribution (Android) and TestFlight (iOS). QA begins testing the staging build against the staging API.</p>
<h3 id="heading-4-staging-is-merged-into-production">4. <code>staging</code> is merged into <code>production</code></h3>
<p>Both workflows fire one final time. Production secrets are injected, builds are obfuscated and signed, debug symbols are uploaded to Sentry, and the final artifacts are submitted to the Google Play Store and App Store Connect. The release goes live on Apple and Google's review timelines with no further human intervention required.</p>
<p>From that first PR to a production submission, not a single command was run manually.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building this pipeline is an upfront investment that pays off from the very first release cycle. What used to be a sequence of error-prone manual steps building locally, signing, uploading, switching configs, and hoping nothing was mixed up is now a fully automated, auditable, and repeatable process that runs the moment code moves between branches.</p>
<p>The architecture we built here does more than just automate builds. The PR quality gate enforces team standards consistently, so code review becomes a conversation about intent rather than a hunt for formatting issues. The environment-aware config injection eliminates an entire class of production incidents where staging keys made it into a live release. The Sentry symbol upload means your team can debug production crashes with full source visibility even from an obfuscated binary.</p>
<p>Every piece of this pipeline also runs locally. The helper scripts in the <code>scripts/</code> folder are plain Bash so you can call them from your terminal the same way CI calls them. This eliminates the frustrating cycle of pushing a commit just to test a pipeline change.</p>
<p>As your team grows, this foundation scales with you. You can extend the <code>pr_checks.yml</code> to enforce code coverage thresholds, add a performance benchmarking job, or introduce a dedicated security scanning step. You can extend the platform workflows to support multiple flavors, multiple Firebase projects, or staged rollouts on the Play Store. The architecture stays the same – you're just adding new steps to an already working system.</p>
<p>This ensures that standards are met, code quality remains high, you have a proper team structure, clear process and automated post development activities are in place – and at the end of the day, you'll have an optimized engineering approach that will help your team in so many ways.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered Research Automation System with n8n, Groq, and Academic APIs ]]>
                </title>
                <description>
                    <![CDATA[ As a researcher and developer, I found myself spending hours manually searching academic databases, reading abstracts, and trying to synthesize findings across multiple sources. For my work on circula ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-ai-powered-research-automation-system-with-n8n-groq-and-academic-apis/</link>
                <guid isPermaLink="false">69b849372ad6ae5184dbb6b8</guid>
                
                    <category>
                        <![CDATA[ n8n ]]>
                    </category>
                
                    <category>
                        <![CDATA[ freeCodeCamp.org ]]>
                    </category>
                
                    <category>
                        <![CDATA[ General Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 18:17:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d4660bc7-3f3c-4325-bee7-57770e821204.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a researcher and developer, I found myself spending hours manually searching academic databases, reading abstracts, and trying to synthesize findings across multiple sources.</p>
<p>For my work on circular economy and battery recycling, I needed a way to query multiple databases at once without the manual fatigue.</p>
<p>In this tutorial, you'll build an automated research pipeline using n8n that reduces roughly six hours of manual literature review into a five-minute automated process.</p>
<p>This isn’t a “cool demo workflow.” It’s a production-minded pipeline with parallel collection, normalisation, deduplication, structured AI extraction, scoring, and practical error handling.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-research-takes-too-long">The Problem: Research Takes Too Long</a></p>
</li>
<li><p><a href="#heading-the-tech-stack">The Tech Stack</a></p>
</li>
<li><p><a href="#heading-the-project-structure-how-to-think-about-an-n8n-workflow-like-software">The Project Structure: How to Think About an n8n Workflow Like Software</a></p>
</li>
<li><p><a href="#heading-stage-1-centralized-configuration">Stage 1: Centralised Configuration</a></p>
</li>
<li><p><a href="#heading-stage-2-parallel-api-collection=with-failure-isolation">Stage 2: Parallel API Collection (With Failure Isolation)</a></p>
</li>
<li><p><a href="#heading-stage-3-normalisation-and-deduplication-doifirst-title-fallback">Stage 3: Normalisation and Deduplication (DOI-first, Title fallback)</a></p>
</li>
<li><p><a href="#heading-stage-4-aipowered-content-extraction-strict-json">Stage 4: AI-Powered Content Extraction (Strict JSON)</a></p>
</li>
<li><p><a href="#heading-stage-5-scoring-and-synthesis">Stage 5: Scoring and Synthesis</a></p>
</li>
<li><p>[Beginner-Friendly Evals (Retrieval and Extraction QA)(#heading-beginnerfriendly-evals-retrieval-and-extraction-qa)</p>
</li>
<li><p><a href="#heading-key-learnings-and-error-handling">Key Learnings and Error Handling</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don’t need to be a DevOps engineer to follow this, but you should have:</p>
<ul>
<li><p>Basic comfort with APIs and JSON (request/response payloads)</p>
</li>
<li><p>Familiarity with spreadsheets (Google Sheets basics)</p>
</li>
<li><p>Willingness to use a small amount of JavaScript inside n8n Function/Code nodes</p>
</li>
</ul>
<p>Access to:</p>
<ul>
<li><p>An n8n instance (self-hosted or cloud)</p>
</li>
<li><p>A Groq API key (or a compatible LLM provider)</p>
</li>
<li><p>Optional API keys, depending on the databases you use</p>
</li>
</ul>
<p>What you’ll build assumes:</p>
<ul>
<li><p>You’re extracting from metadata + abstracts (not downloading full PDFs).</p>
</li>
<li><p>You can accept that some sources will occasionally rate-limit or return partial results (and your workflow will be designed to survive this).</p>
</li>
</ul>
<h2 id="heading-the-problem-research-takes-too-long">The Problem: Research Takes Too Long</h2>
<p>Manual research is often a bottleneck for innovation. Before building this automation, my workflow involved searching multiple academic databases, scanning abstracts, and manually extracting key findings. This process was not only slow but also prone to human error and inconsistent note-taking.</p>
<p>The goal of this automation is to provide a “full-stack research assistant” that handles the heavy lifting of collecting candidate papers, removing duplicates, extracting consistent fields, scoring relevance and quality, and delivering a curated daily or weekly report, so you can spend your time on high-level synthesis rather than repetitive collection.</p>
<h2 id="heading-the-tech-stack">The Tech Stack</h2>
<p>This workflow leverages a combination of automation tooling, high-speed LLM inference, and academic metadata providers.</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>n8n</td>
<td>The workflow engine that orchestrates all steps</td>
</tr>
<tr>
<td>Groq</td>
<td>Runs a fast LLM (for example, Llama 3.3 70B) for structured extraction/synthesis</td>
</tr>
<tr>
<td>Semantic Scholar / OpenAlex</td>
<td>Broad academic coverage for metadata, abstracts, citations</td>
</tr>
<tr>
<td>arXiv / PubMed</td>
<td>Strong specialised coverage (preprints, life sciences)</td>
</tr>
<tr>
<td>Google Sheets</td>
<td>A lightweight “research database” for storage + history</td>
</tr>
</tbody></table>
<p>Notes: coverage varies by provider. Some APIs return abstracts reliably, while others may omit them. Your pipeline should treat missing abstracts as a normal case, not a failure.</p>
<h2 id="heading-the-project-structure-how-to-think-about-an-n8n-workflow-like-software">The Project Structure: How to Think About an n8n Workflow Like Software</h2>
<p>While n8n is a visual tool, it helps to design your workflow as modular stages to avoid the “spaghetti workflow” problem.</p>
<pre><code class="language-text">.
├── configuration/         # Keywords, thresholds, limits, date filters
├── collectors/            # Parallel HTTP request nodes (multiple sources)
├── processing/            # Normalization + deduplication code nodes
├── extraction/            # LLM extraction nodes (strict JSON)
├── scoring/               # Relevance + quality scoring + filtering
└── delivery/              # Google Sheets + email/HTML report
</code></pre>
<p>Design principle: each stage should produce a clean, predictable output shape that the next stage can rely on.</p>
<h2 id="heading-stage-1-centralised-configuration">Stage 1: Centralised Configuration</h2>
<p>Instead of hardcoding search parameters (keywords, min year, citation thresholds) across multiple nodes, use one configuration node to define workflow variables.</p>
<p>This matters for maintainability (change a value once, not in ten nodes), reusability (repurpose the entire pipeline by swapping one config object), and debuggability (log the config at the start of each run so you can reproduce results).</p>
<p>Use a Set node, or a Code node returning JSON like this:</p>
<pre><code class="language-json">{
  "keywords": "circular economy battery recycling remanufacturing",
  "min_year": 2020,
  "max_results_per_source": 10,
  "min_citations": 2,
  "relevance_threshold": 15,
  "batch_size": 10
}
</code></pre>
<p>Tip: keep numeric fields as numbers (not strings) to avoid scoring bugs later.</p>
<h2 id="heading-stage-2-parallel-api-collection-with-failure-isolation">Stage 2: Parallel API Collection (With Failure Isolation)</h2>
<p>Your workflow should query multiple sources simultaneously. In n8n, you can branch from your configuration node into multiple HTTP Request nodes, and then merge results later.</p>
<p>The production mindset here is simple: APIs fail. Rate limits happen. Providers return partial data. The key is to prevent one failing collector from crashing the whole run.</p>
<p>To implement this, on each HTTP Request node, enable <strong>Continue On Fail</strong> (or the equivalent “don’t stop workflow” behaviour). Then, in the normalisation stage, treat missing or failed outputs as empty arrays so downstream stages still run.</p>
<p>In practice, it also helps to set explicit timeouts and add a small retry policy (one to two retries) for transient failures. “Good” looks like this: if two out of five sources fail, you still produce a useful report from the remaining three, and you log which sources failed so you can investigate later.</p>
<h2 id="heading-stage-3-normalisation-and-deduplication-doi-first-title-fallback">Stage 3: Normalisation and Deduplication (DOI-first, Title fallback)</h2>
<p>Each academic API returns different field names and shapes. One might use <code>title</code>, another <code>display_name</code>, another <code>paper_title</code>. Your next stage should normalise all inputs into one schema.</p>
<h3 id="heading-target-normalised-schema">Target normalised schema</h3>
<p>Here’s a simple baseline schema (expand later as needed):</p>
<pre><code class="language-json">{
  "title": "string",
  "abstract": "string|null",
  "doi": "string|null",
  "year": 2024,
  "citations": 12,
  "url": "string|null",
  "source": "Semantic Scholar|OpenAlex|arXiv|PubMed"
}
</code></pre>
<h3 id="heading-what-deduping-by-doi-means-and-what-a-doi-is">What deduping by DOI means (and what a DOI is)</h3>
<p>A <strong>DOI</strong> (Digital Object Identifier) is a unique, persistent identifier assigned to many scholarly publications. If a paper has a DOI, that DOI functions like a stable ID: the same paper may appear in multiple databases with slightly different metadata, but the DOI should remain consistent.</p>
<p>So, <strong>deduping by DOI</strong> means: if two records share the same DOI, treat them as the same paper and keep only one.</p>
<p>When a DOI is missing (which is common for some preprints and some API responses), the fallback is to dedupe using a normalised title key, lowercased, trimmed, punctuation stripped, and whitespace collapsed. It’s not as perfect as DOI-based matching, but it’s a strong pragmatic backup.</p>
<h3 id="heading-what-normalise-into-a-unified-object-means-whats-happening-in-the-code">What “normalise into a unified object” means (what’s happening in the code)</h3>
<p>“Normalise into a unified object” simply means converting every provider’s raw response into the same predictable shape (the schema above). Once everything looks the same, downstream steps, such as deduplication, scoring, AI extraction, and storage, become straightforward because they don’t need provider-specific logic.</p>
<p>In the code below, that’s what the <code>normalized</code> object is: it maps Semantic Scholar’s fields (<code>paper.title</code>, <code>paper.externalIds.DOI</code>, <code>paper.citationCount</code>) into your standard fields (<code>title</code>, <code>doi</code>, <code>citations</code>, etc.). After that, the workflow generates a dedupe key (<code>doi:...</code> if DOI exists, otherwise <code>title:...</code>) and uses a <code>Set</code> to keep only the first occurrence.</p>
<h4 id="heading-example-n8n-code-node-normalisation-dedupe-pattern">Example n8n Code Node (Normalisation + Dedupe Pattern)</h4>
<pre><code class="language-javascript">const itemsIn = $input.all();

const seen = new Set();
const results = [];

function titleKey(t) {
  return (t || "")
    .toLowerCase()
    .replace(/[\W_]+/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

for (const item of itemsIn) {
  // Example: Semantic Scholar response shape
  const papers = item.json?.data || [];

  for (const paper of papers) {
    // "Normalize into a unified object":
    // take the provider-specific fields and map them into our standard schema.
    const normalized = {
      title: paper.title || null,
      abstract: paper.abstract || null,
      doi: paper.externalIds?.DOI || null,
      year: paper.year || null,
      citations: paper.citationCount || 0,
      url: paper.url || null,
      source: "Semantic Scholar",
    };

    if (!normalized.title) continue;

    // Dedupe key: DOI is strongest; title is fallback
    const key = normalized.doi
      ? `doi:${normalized.doi.toLowerCase()}`
      : `title:${titleKey(normalized.title)}`;

    if (seen.has(key)) continue;
    seen.add(key);

    results.push(normalized);
  }
}

return results.map(r =&gt; ({ json: r }));
</code></pre>
<p>Production-minded note: keep a field like <code>source</code> so you can debug where bad metadata is coming from later.</p>
<h2 id="heading-stage-4-ai-powered-content-extraction-strict-json">Stage 4: AI-Powered Content Extraction (Strict JSON)</h2>
<p>Once you have a deduplicated list of papers, you can send each paper (or a small batch) to Groq for structured extraction.</p>
<h3 id="heading-why-structured-output-matters">Why structured output matters</h3>
<p>If your LLM returns narrative text instead of JSON, misses fields, or emits malformed JSON, your workflow breaks downstream. In a production workflow, that’s not a rare edge case; it’s something you should expect and design around.</p>
<p>That’s why you’ll use strict schema prompting <em>and</em> validate responses downstream.</p>
<h3 id="heading-system-prompt-vs-user-prompt-and-how-to-compose-them">System prompt vs user prompt (and how to compose them)</h3>
<p>A helpful way to think about prompts in production is:</p>
<ul>
<li><p>The <strong>system prompt</strong> defines the <em>non-negotiable contract</em>: output format, allowed keys, no commentary, and what to do in uncertain cases. This is where you say “return ONLY valid JSON” and “no extra keys.”</p>
</li>
<li><p>The <strong>user prompt</strong> provides the <em>variable data</em> for this specific request: title, year, citations, abstract, and the exact schema you want filled.</p>
</li>
</ul>
<p>Composing them this way keeps your workflow stable. The system prompt stays mostly constant (your formatting contract), while the user prompt changes per paper (your payload). It also makes debugging easier: if outputs start failing, you can adjust the system constraints without rewriting every payload template.</p>
<h3 id="heading-suggested-extraction-schema">Suggested extraction schema</h3>
<p>Extract only what you can support from abstract-level data:</p>
<ul>
<li><p><code>research_question</code></p>
</li>
<li><p><code>methodology</code></p>
</li>
<li><p><code>key_findings</code></p>
</li>
<li><p><code>limitations</code></p>
</li>
<li><p><code>notes</code> (for missing abstract / ambiguity)</p>
</li>
</ul>
<h3 id="heading-example-prompt-system-user">Example prompt (system + user)</h3>
<p><strong>System:</strong></p>
<p>You are a research extraction engine. You must return ONLY valid JSON.<br>No markdown. No extra keys. No commentary.<br>If the abstract is missing or too vague, set fields to null and include a reason in "notes".</p>
<p><strong>User:</strong></p>
<p>Extract structured fields from this paper.</p>
<p>TITLE: {{title}}<br>YEAR: {{year}}<br>CITATIONS: {{citations}}<br>ABSTRACT: {{abstract}}</p>
<p>Return JSON with keys:<br>research_question (string|null)<br>methodology (string|null)<br>key_findings (array of strings)<br>limitations (array of strings)<br>notes (string)</p>
<p>Model settings: keep temperature low (around 0.2–0.3) and keep responses short and structured.</p>
<h3 id="heading-batch-processing-to-avoid-timeouts">Batch processing to avoid timeouts</h3>
<p>Instead of sending 50 papers at once, process them in batches (for example, 10). This reduces latency spikes, failure blast radius, and cost surprises. Smaller batches also make it easier to retry only the failing chunk rather than re-running everything.</p>
<h2 id="heading-stage-5-scoring-and-synthesis">Stage 5: Scoring and Synthesis</h2>
<p>Not every retrieved paper is worth your time. Without scoring, your pipeline becomes a firehose: you’ve automated collection, but you still have to manually decide what to read. Scoring is what turns “a big list of results” into a shortlist you can trust.</p>
<p>I recommend computing two signals:</p>
<ul>
<li><p><strong>Relevance</strong>: Is this actually about your research question?</p>
</li>
<li><p><strong>Quality/priority</strong>: If it’s relevant, is it worth reading first?</p>
</li>
</ul>
<p>For <strong>relevance</strong>, keep it simple and explainable. Count keyword hits in the title and abstract (and optionally in extracted <code>key_findings</code>). Title matches should be weighted higher because titles are deliberately compact summaries. Abstract hits are useful too, but cap them so long abstracts don’t dominate the score.</p>
<p>For <strong>quality/priority</strong>, use lightweight metadata you already have. Recency is a strong signal in fast-moving areas, and citations can help, but they should be treated as a weak signal (and capped) so newer high-value papers aren’t unfairly penalised.</p>
<p>A solid first scoring model is: add a title bonus, add a capped abstract bonus, add a capped citations bonus, and add a small recency bonus for papers from the last two years. Then filter using the <code>relevance_threshold</code> results from Stage 1. The advantage of this approach is that it’s easy to debug and tune: you can always explain why a paper passed or failed.</p>
<p>Once you’ve filtered down to your “gold” set, synthesis becomes safer and more useful. Write one row per accepted paper to Google Sheets, then generate a daily/weekly HTML summary (for example, top 5 papers with 1–2 key findings each) and include links so you can verify quickly.</p>
<h2 id="heading-beginner-friendly-evals-retrieval-and-extraction-qa">Beginner-Friendly Evals: Retrieval and Extraction QA</h2>
<p>AI workflows regress silently. A prompt tweak, a model update, or an API schema change can break extraction without throwing an obvious error. Adding lightweight evals is the difference between “it worked last week” and “it’s reliable.”</p>
<p>The goal here isn’t to build a full evaluation framework. It’s to add small, cheap checks that catch the most common failure modes:</p>
<ul>
<li><p>Are collectors still returning results?</p>
</li>
<li><p>Are we actually removing duplicates?</p>
</li>
<li><p>Is the LLM returning valid JSON with the keys we require?</p>
</li>
</ul>
<h3 id="heading-what-it-looks-like-in-n8n-a-concrete-example">What it looks like in n8n (a concrete example)</h3>
<p>A simple implementation is to add an <strong>“Assertions” Code node</strong> immediately after your extraction step, plus (optionally) another one after normalisation/deduplication.</p>
<p>At a high level, the workflow section looks like:</p>
<ol>
<li><p>Collectors (parallel HTTP Request nodes)</p>
</li>
<li><p>Merge results</p>
</li>
<li><p>Normalise + dedupe (Code node)</p>
</li>
<li><p>Split in Batches (optional)</p>
</li>
<li><p>LLM extraction (Groq/OpenAI-compatible node)</p>
</li>
<li><p><strong>Assertions (Code node)</strong></p>
</li>
<li><p>If node (pass/fail)</p>
</li>
<li><p>Delivery (Sheets + email)</p>
</li>
</ol>
<h3 id="heading-example-assertions-code-node-after-extraction">Example: Assertions code node after extraction</h3>
<p>This code node assumes each item is a paper with:</p>
<ul>
<li><p><code>title</code>, <code>abstract</code> in the normalised fields, and</p>
</li>
<li><p>an <code>extraction</code> field (or whatever you name it) containing the LLM response as an object or JSON string.</p>
</li>
</ul>
<p>Adapt the field name to match your actual node output, but the pattern is the same: parse, validate required keys, compute percentages, then decide whether to fail or warn.</p>
<pre><code class="language-javascript">const items = $input.all();

let total = items.length;
let withTitle = 0;
let withAbstract = 0;

let parseOk = 0;
let schemaOk = 0;

const requiredKeys = [
  "research_question",
  "methodology",
  "key_findings",
  "limitations",
  "notes",
];

const failures = [];

for (let i = 0; i &lt; items.length; i++) {
  const p = items[i].json;

  if (p.title &amp;&amp; String(p.title).trim().length &gt; 0) withTitle++;
  if (p.abstract &amp;&amp; String(p.abstract).trim().length &gt; 0) withAbstract++;

  // Adjust this depending on where you store the model output:
  const raw = p.extraction ?? p.llm ?? p.model_output;

  let obj = null;
  try {
    obj = typeof raw === "string" ? JSON.parse(raw) : raw;
    parseOk++;
  } catch (e) {
    failures.push({ index: i, title: p.title || null, reason: "JSON parse failed" });
    continue;
  }

  const hasAllKeys = requiredKeys.every(k =&gt; Object.prototype.hasOwnProperty.call(obj, k));
  if (!hasAllKeys) {
    failures.push({ index: i, title: p.title || null, reason: "Missing required keys" });
    continue;
  }

  // Optional: ensure arrays are arrays
  const arraysOk = Array.isArray(obj.key_findings) &amp;&amp; Array.isArray(obj.limitations);
  if (!arraysOk) {
    failures.push({ index: i, title: p.title || null, reason: "key_findings/limitations not arrays" });
    continue;
  }

  schemaOk++;
}

const pct = (n) =&gt; (total === 0 ? 0 : Math.round((n / total) * 100));

const report = {
  total_papers: total,
  pct_with_title: pct(withTitle),
  pct_with_abstract: pct(withAbstract),
  pct_extraction_json_parse_ok: pct(parseOk),
  pct_extraction_schema_ok: pct(schemaOk),
  failures_sample: failures.slice(0, 5),
};

// Decide pass/fail thresholds
const HARD_FAIL_PARSE_BELOW = 90;
const HARD_FAIL_SCHEMA_BELOW = 85;

const shouldFail =
  report.pct_extraction_json_parse_ok &lt; HARD_FAIL_PARSE_BELOW ||
  report.pct_extraction_schema_ok &lt; HARD_FAIL_SCHEMA_BELOW;

return [
  {
    json: {
      eval_report: report,
      shouldFail,
    },
  },
];
</code></pre>
<p>Then add an <strong>If node</strong>:</p>
<ul>
<li><p>If <code>shouldFail</code> is true, then route to an “Alert/Stop” branch (Slack/email/log) and optionally stop the workflow.</p>
</li>
<li><p>If false, then continue to the delivery stage.</p>
</li>
</ul>
<p>This is the automation equivalent of unit tests: small, cheap, and extremely effective. It also gives you a concrete paper trail when something changes upstream.</p>
<h2 id="heading-key-learnings-and-error-handling">Key Learnings and Error Handling</h2>
<p>Building this automation taught me that the best workflows are designed for failure.</p>
<p>First, error resilience is not optional. Never let one failing API crash the workflow. Use “Continue On Fail” on your HTTP nodes, merge partial results, and log which sources failed in your final report so you can debug without losing an entire run.</p>
<p>Second, batching is your friend. Process papers in batches (often 5–15) to reduce timeouts and cost spikes. Keep LLM payloads small and focused on what you actually need (metadata + abstract), and retry transient failures once rather than repeatedly hammering the model or API.</p>
<p>Third, structured prompting is what makes AI reliable in automation. A strict JSON schema is the difference between a workflow that runs unattended and one that breaks randomly. Keep temperature low, enforce the schema in the system prompt, and validate everything downstream with simple parse-and-assert checks.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A good research pipeline doesn’t just retrieve papers – it turns scattered results into a consistent, deduplicated, scored, and review-ready shortlist you can trust.</p>
<p>By treating your n8n workflow like software modular stages, strict contracts between steps, and lightweight eval checks, you can reduce hours of manual literature review into a fast, repeatable process that survives real-world API failures and model quirks.</p>
<p>If you build this with good defaults (failure isolation, batching, normalisation, strict JSON extraction, and simple scoring), you end up with something you can run daily or weekly and actually rely on without the manual fatigue.</p>
<h3 id="heading-about-me">About Me</h3>
<p>I am Chidozie Managwu, an award-winning AI Product Architect and founder focused on helping global tech talent build real, production-ready skills. I contribute to global AI initiatives as a GAFAI Delegate and lead the AI Titans Network, a community for developers learning how to ship AI products.</p>
<p>My work has been recognised with the Global Tech Hero award and featured on platforms like HackerNoon.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Penetration Testing — Services vs Automated Platforms: What’s Better in 2026? ]]>
                </title>
                <description>
                    <![CDATA[ In 2026, cybersecurity teams face more threats than ever before. Attack surfaces are broad, technology stacks are complex, and adversaries are quick to exploit weak points. Against this backdrop, comp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/penetration-testing-services-vs-automated-platforms-what-is-better/</link>
                <guid isPermaLink="false">69b843d22ad6ae5184d73e34</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pentesting ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 17:54:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/820ccff8-9ef7-4b12-a7a9-113c5a71abdc.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In 2026, cybersecurity teams face more threats than ever before.</p>
<p>Attack surfaces are broad, technology stacks are complex, and adversaries are quick to exploit weak points.</p>
<p>Against this backdrop, companies must decide how best to test their defences.</p>
<p>Two main approaches have emerged as leaders: human-led penetration testing services and automated testing platforms. Each has strengths and limitations. Choosing the right one depends on your security goals, risk tolerance, and budget.</p>
<p>At its core, <a href="https://www.cloudflare.com/learning/security/glossary/what-is-penetration-testing/">penetration testing</a> is about finding security holes before attackers do. But how you get there matters.</p>
<p>Human experts bring creativity and real-world insight, while automated platforms offer scale and speed.</p>
<p>This article explores both approaches and compares top providers to help you decide what’s better for your organization in 2026.</p>
<h3 id="heading-what-well-cover">What we'll cover:</h3>
<ol>
<li><p><a href="#heading-what-are-penetration-testing-services">What Are Penetration Testing Services?</a></p>
</li>
<li><p><a href="#heading-what-are-automated-penetration-testing-platforms">What Are Automated Penetration Testing Platforms?</a></p>
</li>
<li><p><a href="#heading-why-the-debate-matters-in-2026">Why the Debate Matters in 2026</a></p>
<ul>
<li><p><a href="#heading-depth-of-testing-humans-vs-machines">Depth of Testing: Humans vs Machines</a></p>
</li>
<li><p><a href="#heading-speed-and-frequency-of-testing">Speed and Frequency of Testing</a></p>
</li>
<li><p><a href="#heading-cost-considerations">Cost Considerations</a></p>
</li>
<li><p><a href="#heading-integration-with-security-workflows">Integration with Security Workflows</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-real-world-context-top-providers-in-2026">Real World Context: Top Providers in 2026</a></p>
</li>
<li><p><a href="#heading-compliance-and-reporting">Compliance and Reporting</a></p>
</li>
<li><p><a href="#heading-which-one-should-you-choose-in-2026">Which One Should You Choose in 2026?</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ol>
<h2 id="heading-what-are-penetration-testing-services">What Are Penetration Testing Services?</h2>
<p>Penetration testing services are engagements where cybersecurity professionals actively probe your systems to find vulnerabilities. These experts use a mix of tools, manual techniques, and real-world attack simulations to surface weaknesses that machines might miss.</p>
<p>These services may include scheduled tests, one-time assessments, and ongoing engagements. Many providers tailor their approach to the environment being tested, whether that’s a corporate network, web application, cloud infrastructure, or mobile ecosystem.</p>
<p>Human testers think like attackers, combining automated scans with logic and adaptability that machines cannot replicate on their own.</p>
<p>These engagements are typically measured in reports, debrief sessions, and clear remediation guidance. The human element is the defining factor. A skilled tester doesn’t just find flaws. They understand context, creative exploit paths, and business impact.</p>
<h2 id="heading-what-are-automated-penetration-testing-platforms">What Are Automated Penetration Testing Platforms?</h2>
<p>Automated penetration testing platforms use software to scan, crawl, and test systems for vulnerabilities. These platforms run scheduled scans or continuous assessments with minimal human intervention. They aim to find flaws early and often, integrating with development pipelines or security operations centers.</p>
<p>Automation brings consistency, speed, and the ability to repeat tests frequently. Many modern platforms use machine learning to prioritize findings and reduce noise. Some offer automation rules that trigger scans based on changes in the environment or codebase.</p>
<p>In contrast to full manual services, platforms are best suited for ongoing baseline assessments and rapid feedback. They are often priced in subscription models and integrate with other tooling like bug tracking systems or <a href="https://www.ibm.com/think/topics/siem">SIEMs</a>. While they can pinpoint known vulnerability patterns efficiently, automated tools are limited in creative attack paths and logic-based exploits.</p>
<h2 id="heading-why-the-debate-matters-in-2026">Why the Debate Matters in&nbsp;2026</h2>
<p>In 2026, the cybersecurity landscape is both more advanced and more hazardous. Organizations operate hybrid clouds, microservices architectures, and complex supply chains.</p>
<p>Threat actors are using AI to scale attacks. In this environment, the question is not only about finding old vulnerabilities but anticipating novel attack methods.</p>
<p>With limited resources, security leaders must choose wisely. Do you invest heavily in services with human experts? Do you adopt automated platforms that test continuously?</p>
<p>Maybe a mix is best. To answer these questions, let’s explore how the two approaches compare across key criteria.</p>
<h3 id="heading-depth-of-testing-humans-vs-machines">Depth of Testing: Humans vs&nbsp;Machines</h3>
<p>Human-led penetration tests shine when deep context and logic are required. Expert testers can chain together multiple issues to compromise a system in ways automated tools don't anticipate. They explore paths, think creatively, and adapt in real time to the environment they encounter.</p>
<p>Automated platforms excel at breadth and repetition. They perform wide sweeps of systems quickly and can generate alerts on common vulnerability classes. They're particularly strong in repetitive tasks like scanning hundreds of endpoints or validating compliance controls.</p>
<p>But platforms often rely on predefined signatures and patterns. They perform poorly when an exploit requires intuition or lateral thinking.</p>
<p>In simple terms, human services dig deep while platforms dig wide.</p>
<h3 id="heading-speed-and-frequency-of-testing">Speed and Frequency of&nbsp;Testing</h3>
<p>Automated platforms have a clear advantage in speed and frequency. They can run multiple scans in parallel, test after every code commit, and provide almost immediate feedback. This makes them ideal for DevOps pipelines and agile environments that change daily.</p>
<p>Penetration testing services, by design, occur on a schedule. A quarterly or annual test may be thorough, but it cannot match the cadence that automated tools provide.</p>
<p>Manual tests take time to plan, execute, and analyze. In fast-moving environments, this might leave gaps between testing windows.</p>
<p>For many organizations, automation fills these gaps, while manual testing provides periodic, deep insight.</p>
<h3 id="heading-cost-considerations">Cost Considerations</h3>
<p>Cost is always a factor. Automated platforms generally come with lower upfront costs compared to human-led engagements. Subscriptions scale with usage and provide continuous assessment for a predictable price. This makes them appealing to midsize companies or teams with limited budgets.</p>
<p>Penetration testing services, especially from reputable consultancies, command higher fees. These reflect labor costs, expertise, and the bespoke nature of the work.</p>
<p>However, the value gained is often more than just flaw detection: it’s expert interpretation, custom exploitation paths, and strategic guidance.</p>
<p>In cost-benefit terms, automated platforms provide the most value per dollar for baseline security, while services deliver high-value insight that can justify a higher cost.</p>
<h3 id="heading-integration-with-security-workflows">Integration with Security Workflows</h3>
<p>Automated platforms are built to integrate with broader security tooling. They often connect to continuous integration/continuous delivery (CI/CD) pipelines, vulnerability management platforms, and ticketing systems. This integration ensures that issues are communicated to the teams who need them most and tracked to resolution.</p>
<p>Penetration testing services can integrate into workflows too, but this usually requires additional coordination. Reports must be ingested into tracking systems and aligned with internal priorities. Some providers offer APIs and extended services that help bridge this gap, but the process typically takes more effort than with automated platforms.</p>
<p>Integration matters because security cannot operate in isolation. Automated platforms fit more naturally into modern DevSecOps workflows, while services provide episodic insights that must be planned and bridged into operations.</p>
<h2 id="heading-real-world-context-top-providers-in-2026">Real World Context: Top Providers in&nbsp;2026</h2>
<p>To illustrate how these approaches manifest in practice, consider a few leading options. Each provider offers different strengths in manual services or automated tooling.</p>
<p>One such provider is <a href="https://xbow.com/pentest">XBOW</a>. XBOW is known for deep manual testing engagements, combining expert human testers with structured methodologies across network, application, and cloud environments. Their work emphasizes real-world attack simulations and strategic risk reporting.</p>
<p>Another well-known provider is <a href="https://www.cobalt.io/">Cobalt</a>. Cobalt blends human expertise with platform-based management. Their Pentest as a Service (PtaaS) model connects testers to client environments through a platform that organizes findings, workflows, and communication. Clients can collaborate with testers, track issues in real time, and integrate results with other systems.</p>
<p>A different model comes from <a href="https://www.synack.com/">Synack</a>. Synack uses a crowd of vetted testers who work with a secure testing platform. This hybrid model aims to combine the creativity of human testers with the scalability and tracking of automated systems. Clients benefit from diverse testing styles and coordinated reporting within a structured platform.</p>
<p>Each of these approaches has merit. Some lean more toward pure services, others toward platform-driven collaboration. Your choice should align with your security maturity and goals.</p>
<h2 id="heading-compliance-and-reporting">Compliance and Reporting</h2>
<p>For regulated industries, compliance matters. Automated platforms often include reporting features that map directly to standards like PCI DSS, HIPAA, or ISO 27001. These reports can be generated on a regular cadence and integrated into audit evidence.</p>
<p>Penetration testing services provide compliance support too, but the reports are typically narrative and bespoke. The real value is in expert interpretation of compliance requirements and guidance on remediating complex findings.</p>
<p>In essence, automation provides structured, repeatable reporting, while services deliver customized insights that may carry more weight with auditors and internal stakeholders.</p>
<h2 id="heading-which-one-should-you-choose-in-2026">Which One Should You Choose in&nbsp;2026?</h2>
<p>There is no one-size-fits-all answer. Many organizations adopt both approaches. Automated platforms serve as the first line of defense by continuously scanning for known issues and tracking progress over time. Human-led services then provide a deeper second layer, uncovering complex issues and offering strategic guidance.</p>
<p>If your environment is highly dynamic, with frequent releases and evolving infrastructure, an automated platform is essential. If you operate in a high-risk sector where attackers are likely to craft bespoke exploits, human-led penetration testing services are indispensable.</p>
<p>Most mature security programs use both. Automation drives frequency and scale. Human services provide depth and insight. Together, they form a layered testing strategy that maximizes coverage and minimizes blind spots.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>In 2026, cybersecurity testing is more sophisticated and essential than ever. Organizations must balance speed, depth, cost, and context when selecting between penetration testing services and automated platforms. While one is not inherently better than the other in all cases, understanding their differences and complementary strengths will help you build a robust security posture.</p>
<p>Automated platforms catch the routine and repetitive, giving continuous visibility into known risks. Human-led services uncover the hidden and unexpected, thinking beyond patterns to simulate real adversaries. For most teams, the future of testing lies in a hybrid approach that leverages both.</p>
<p>By aligning your security goals with the right mix of services and tools, you can stay ahead of threats now and in the years to come.</p>
<p><em>Hope you enjoyed this article. Learn more about me by</em> <a href="https://manishmshiva.me"><em><strong>visiting my website</strong></em></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Autonomous AI Agent with n8n and Decapod ]]>
                </title>
                <description>
                    <![CDATA[ I tried out Open Claw two weeks ago. I loved the potential, but did not enjoy the tool itself. I, like many others, struggled with the installation process. And working from Linux, the Mac specific or ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-autonomous-ai-agent-with-n8n-and-decapod/</link>
                <guid isPermaLink="false">69b1ce1f6c896b0519c1c8f5</guid>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ n8n ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lee Nathan ]]>
                </dc:creator>
                <pubDate>Wed, 11 Mar 2026 20:18:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d27ea304-5db6-4172-823d-3f6aa0612d38.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I tried out Open Claw two weeks ago. I loved the potential, but did not enjoy the tool itself.</p>
<p>I, like many others, struggled with the installation process. And working from Linux, the Mac specific orientation added extra pitfalls. It wasn't always clear whether configuration and management should be done in the docs, the CLI, or the interface.</p>
<p>I found the UI unintuitive and it left me wondering if it wasn't just a dev placeholder. The color choice in particular was especially harsh. All the red tricked the eye and made white text appear green. It also made everything seem like an error message.</p>
<p>I couldn't make heads or tails of the organization and structure. Workspaces, agents, and sessions are all terms I'm familiar with and understand. But the way Open Claw implements them made no sense to me.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/0816135a-a80f-4f56-819a-9c82920f0245.png" alt="A simple n8n workflow that clearly shows how telegram can be connected to an AI agent." style="display:block;margin:0 auto" width="773" height="376" loading="lazy">

<p>Open Claw started as a way to connect a chat tool to an AI. I did that eight months ago with n8n. It's literally only a few nodes. It was so easy that I didn't think anything of it. In my opinion, Open Claw isn’t actually all that special. There’s no part of it that stands out as unique, except for the approach. It’s the Flappy Bird of the agentic AI world.</p>
<p>So I set out to make my own. And within a few hours, I'd whipped up a simple working prototype vibe-coded with Python and connected to Open WebUI (OWUI).</p>
<p>But I wanted to see what prompt OWUI was sending the agent, exactly. Now, if I was actually a Python guy, I would have done some console output. But instead, I went for my favorite tool: n8n (a powerful low-code automation system). And that's where things got interesting.</p>
<h2 id="heading-about-this-handbook">About This Handbook</h2>
<p>This handbook will introduce you to agentic AI creation using a hands-on approach and a starter project I created called Decapod.</p>
<p>Decapod is not a self-contained SaaS offering. There is no part of it that is black boxed and unavailable to hack on. Decapod is a collection of <code>docker-compose.yml</code> containers, scripts, AI agent prompts, and n8n workflows that work together to help give you a leg up on your path to building your own agentic AI empire.</p>
<p>Concepts and technologies you'll be introduced to and using:</p>
<ul>
<li><p>Agentic AI with tools and skills</p>
</li>
<li><p>Docker containers with Docker Compose</p>
</li>
<li><p>Open WebUI</p>
</li>
<li><p>n8n</p>
</li>
<li><p>S3 and MinIO</p>
</li>
<li><p>Caddy</p>
</li>
<li><p>Postgres</p>
</li>
</ul>
<p>For a list of required skills, services, and tools, please check out the "Requirements and Processes" section.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-decapod-the-diyers-dream-agent">Decapod - The DIYer's Dream Agent</a></p>
</li>
<li><p><a href="#heading-how-decapod-works">How Decapod Works</a></p>
<ul>
<li><p><a href="#heading-core-engine">Core Engine</a></p>
</li>
<li><p><a href="#heading-supakitchen-supabase-on-a-budget">Supakitchen - Supabase on a Budget</a></p>
</li>
<li><p><a href="#heading-open-webui-ai-chat-with-all-the-bells-and-whistles">Open WebUI - AI Chat With All the Bells and Whistles</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-requirements-and-processes-tools-i-use-and-recommend">Requirements and Processes - Tools I Use and Recommend</a></p>
<ul>
<li><a href="#heading-the-checklist">The Checklist</a></li>
</ul>
</li>
<li><p><a href="#heading-assembling-the-dream-team-ikea-style">Assembling the Dream Team - Ikea Style</a></p>
<ul>
<li><p><a href="#heading-accessing-your-vps-with-cursor-and-ssh">Accessing Your VPS With Cursor and SSH</a></p>
</li>
<li><p><a href="#heading-installing-and-configuring-the-docker-containers">Installing and Configuring the Docker Containers</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-configuration-and-wiring">Configuration and Wiring</a></p>
<ul>
<li><p><a href="#heading-initiate-the-database">Initiate the Database</a></p>
</li>
<li><p><a href="#heading-a-little-minio">A Little MinIO</a></p>
</li>
<li><p><a href="#heading-adding-the-workflows">Adding the Workflows</a></p>
</li>
<li><p><a href="#heading-getting-started-with-n8n">Getting Started With n8n</a></p>
</li>
<li><p><a href="#heading-now-get-owui-to-talk-to-decapod">Now, Get OWUI to Talk to Decapod</a></p>
</li>
<li><p><a href="#heading-there-was-supposed-to-be-an-earth-shattering-kaboom">There Was Supposed to Be an Earth Shattering Kaboom</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-ever-present-hello-world">The Ever-Present "Hello World"</a></p>
</li>
<li><p><a href="#heading-into-the-future">Into the Future!</a></p>
<ul>
<li><p><a href="#heading-a-work-in-progress">A Work in Progress</a></p>
</li>
<li><p><a href="#heading-adding-your-own-skills-limitless-potential">Adding Your Own Skills - Limitless Potential</a></p>
</li>
<li><p><a href="#heading-future-plans">Future Plans</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-got-questions-meet-captain-finn">Got Questions? Meet Captain Finn!</a></p>
</li>
</ul>
<h2 id="heading-decapod-the-diyers-dream-agent">Decapod – The DIYer's Dream Agent</h2>
<p>I'll be honest. I'd never even considered the security issues with Open Claw at first. But they're enormous! Let's open a giant hole in our server and give a fledgling alien intelligence root access and all of our API keys. What could possibly go wrong?</p>
<p>Decapod isn't a monolithic app. It's a collection of tools and n8n workflows that give you complete control over your agent and its tools. It's a framework to give <a href="https://monday.com/appdeveloper/blog/citizen-developer/">citizen developers</a> a leg up.</p>
<p>By switching to n8n, I accidentally solved a ton of issues and made a far superior (in my opinion) project:</p>
<ul>
<li><p>Double (or triple if you choose to host in a VPS) sandboxed security. My agent lives inside of n8n inside of a Docker container inside of a VPS.</p>
</li>
<li><p>The agent never sees a single API key or even ever needs to know exactly how you're connecting services. Credentials are handled by n8n.</p>
</li>
<li><p>Universal access – I prefer OWUI. But literally anything that can connect to a standard OpenAI API endpoint can connect to Decapod.</p>
</li>
<li><p>Over 1,000 integrations – What n8n does best is connecting any API to any other API via drag-and-drop nodes. And there are more than <a href="https://community.n8n.io/t/master-list-of-every-n8n-node/155146">1,000 of them</a>.</p>
</li>
<li><p>No more sketchy skills – Decapod uses skills, but they have to actually be connected to n8n workflows and nodes to work.</p>
</li>
</ul>
<p>More problems Decapod solves:</p>
<ul>
<li><p>Fewer tokens burned – Decapod maintains a clean boundary between what's best handled with code/logic and what's best handled by AI.</p>
</li>
<li><p>No endless loops and hung jobs – Decapod uses a jobs and tasks system that the AI can manage. So if it sees that a task has failed, it can change tasks or suspend the job.</p>
</li>
<li><p>HITL (Human In The Loop) – You can add a HITL sub-workflow before any AI skill to give them permission to proceed or not.</p>
</li>
<li><p>An MVP you can trust – The core Decapod system is just an MVP. But it's built on exclusively mature, open source, enterprise ready solutions: n8n, Open WebUI, Docker, Caddy, Postgres, and MinIO.</p>
</li>
</ul>
<h2 id="heading-how-decapod-works">How Decapod Works</h2>
<p>Decapod is middleware that acts like an OpenAI API. But it intercepts the API call and does agent work with the real API.</p>
<p>The OpenAI API standard is the most widely used in the industry. Almost every tool, like Open WebUI, Zed, and Obsidian have ways to connect to the OpenAI standard. So those tools can also connect to Decapod.</p>
<p>Decapod itself can connect to any API and pass available models through to other tools. I strongly prefer and recommend OpenRouter. OpenRouter also uses the OpenAI standard, but lets you connect to hundreds of mainstream and indie models under the same pricing system. Decapod is configured to work with OR out of the box.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/da54b254-62b5-4e4a-b5d3-b1de7dd5f0fe.png" alt="An n8n workflow with advanced routing." style="display:block;margin:0 auto" width="734" height="451" loading="lazy">

<p>This is an image of the Decapod agent tool router – one of the key n8n workflows in Decapod.</p>
<h3 id="heading-core-engine">Core Engine</h3>
<p>Decapod consists of an agent with tools and skills. By tools, I mean the agentic tools that an AI can access to perform tasks as part of the API. And by skills, I'm referring to <a href="https://agentskills.io/home">Anthropic's Agent Skills standard</a>. It's the same skills standard used by Open Claw.</p>
<p>The Decapod agent has a limited, immutable set of tools for managing Decapod's state and job queue. One tool is used to call skills. Skills are dynamic and you can add as many as you like mid-flight.</p>
<p>Each skill consists of core instructions, followed by JSON specs. The agent builds a skill request based on the JSON and calls the use_skill tool to have it executed. Then Decapod calls a sub-workflow with a name that matches the skill and sends it the JSON.</p>
<p>One skill = one sub-workflow. JSON specs = sub-workflow's expected input.</p>
<p>When Decapod receives a user message, it passes it to the agent. If it's just a message, the agent responds. If it's a call to action, the agent picks a tool and gets to work.</p>
<p>Decapod loops through each job in the queue, handling the agent's tool calls and passing it back the results. When the agent is done, it concludes the job and stops sending tool calls. The final message is passed back to the user.</p>
<h3 id="heading-supakitchen-supabase-on-a-budget">Supakitchen – Supabase on a Budget</h3>
<p>I'm a huge fan of Supabase. It's all the fun of Firebase, except with data normalization. But I'm self-hosting Decapod because paying $20 per month for each of five or more services doesn't sit right with me.</p>
<p>As a mad scientist, I like to be able to try different tools without dealing with the freemium hoops. So I'm running Decapod on a Hetzner VPS with 8 gigs of RAM for about $18 per month. Those 8 gigs go really far in the self-hosted world, but Supabase is heavy.</p>
<p>What I really wanted was to give my agent file access and a database. I accomplished that with MinIO and Postgres. No real-time data, but my agent is async anyway. And agent authentication is done through n8n. So it's good enough.</p>
<p>But you do you! Decapod can work with any S3 compatible file store and any Postgres database. So if you want to use Supabase instead, go for it!</p>
<h3 id="heading-open-webui-ai-chat-with-all-the-bells-and-whistles">Open WebUI – AI Chat With All the Bells and Whistles</h3>
<p>You can use chat tools, like Discord, Telegram, Slack, and others, to chat with your AI easily enough. But if you want multiple sessions or to use different models, it can be tricky.</p>
<p>The easiest tool to set up and work with, by far, is Telegram. You get chat, UI elements, and even embedded apps without having to host your own server, like you do with Discord. I once used it to create a HITL lead qualification tool in a few hours.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/2f6501bc-b72e-4b69-bc7d-662d91d8746f.jpg" alt="A Telegram session showing buttons and commands for a lead gen system." style="display:block;margin:0 auto" width="945" height="2048" loading="lazy">

<p>BUT! While Telegram and friends do get the job done, if you want a new session you have to create a new bot for each and every one. If you want to switch models, you need to add /slash commands. If you want context management, you have to handle that server side.</p>
<p>That's why I prefer Open WebUI. OWUI gives you everything you expect from all of the best mainstream AI offerings, but with a direct tap to the API.</p>
<ul>
<li><p>It works great on browser and mobile as a progressive web app (PWA).</p>
</li>
<li><p>You can mod it with Python.</p>
</li>
<li><p>It has many ways to manage and supply context, including nested projects/folders and RAG support.</p>
</li>
<li><p>You can collaboratively work on notes with AI.</p>
</li>
</ul>
<p>Those are a few of my favorite features, but there are <a href="https://docs.openwebui.com/features/">so many more</a>. Why reinvent the wheel when the absolute best solution already exists?</p>
<h2 id="heading-requirements-and-processes-tools-i-use-and-recommend">Requirements and Processes – Tools I Use and Recommend</h2>
<p>Welcome to my lab-or-a-tory. We're out there on the fringes of agentic AI now. Doing weird experiments by stitching together pieces and parts. Let me show you how I work and tell you where you can and can't stray from my process.</p>
<p>Decapod is a finished MVP and should work right out of the box with minimal headache. But it doesn't have more than a few skills yet. So you'll need to build your own until it takes off. Fortunately, your Decapod agent can help.</p>
<h3 id="heading-the-checklist">The Checklist</h3>
<p><strong>Skills:</strong></p>
<ul>
<li><p>✅ A generalist's mindset, problem-solving skills, and a sense of adventure.</p>
<ul>
<li><p>You don't have to be an expert at anything to install Decapod. I'm not, and I built it.</p>
</li>
<li><p>But you do have to be comfortable with many different technologies.</p>
</li>
</ul>
</li>
<li><p>✅ The command line, Docker, and probably Node. Decapod is self hosted. So you'll need to get your hands a bit dirty.</p>
</li>
<li><p>✅ The ability to read and write a little JavaScript. This helps a lot with n8n code nodes to give it more utility.</p>
</li>
<li><p>✅ Familiarity with JSON and APIs. Everything in n8n is about passing JSON from node to node. And n8n is nothing if not a universal API connector.</p>
</li>
</ul>
<p><strong>Services:</strong></p>
<ul>
<li><p>✅ A domain name with DNS access.</p>
<ul>
<li><p>This is critical for n8n to work properly due to CORS and security issues.</p>
</li>
<li><p>Also, the OWUI PWA doesn't work when hosted through an IP. It's just a web page at that point.</p>
</li>
<li><p>Plus, it's just better for security overall with https support.</p>
</li>
<li><p>If cost is an issue, you can get an <a href="https://gen.xyz/">all-digit domain name from gen.xyz</a> for $0.99. Seems legit, but I haven't tried it myself.</p>
</li>
</ul>
</li>
<li><p>✅ A dedicated VPS with SSH access. (SSH access should be standard for any VPS.)</p>
<ul>
<li><p>You can technically host this on your own PC if you know it will be running 24/7. But using a VPS will give you peace of mind and avoid complicating your PC.</p>
</li>
<li><p>Big-name solutions like AWS and Google Cloud can wind up going off the rails and costing you big bucks if you don't know exactly what you're doing. Better to stick with less enterprise-oriented offerings. I've used the following:</p>
<ul>
<li><p><a href="https://www.hetzner.com/">Hetzner</a> – My current personal favorite. Germany based. High quality and affordable pricing with a few American servers. Even more affordable with European servers.</p>
</li>
<li><p><a href="https://www.digitalocean.com/">Digital Ocean</a> – US based. Can't go wrong. Decent prices. Many offerings. Almost exclusively American servers.</p>
</li>
<li><p><a href="https://webdock.io/en">Webdock</a> – Denmark based. The most affordable of the bunch.</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p>✅ An OpenRouter account. OR provides a universal interface for hundreds of AI models. There's no freemium upsell, like with Hugging Face, but there is a percentage add on when you buy credits/tokens. I feel like it's worth the extra fee to be able to easily swap from Claude to Kimi to GPT to DeepSeek as I please without more keys, more accounts, and more wiring. But this is optional. You can plug Decapod right into Kimi or Gemini and just leave it there if you like.</p>
</li>
</ul>
<p><strong>Tools:</strong></p>
<ul>
<li><p>✅ Cursor, or similar. I love Cursor. It matches my hands-on style. If you're freestyling and dreaming something into creation as you build it, AI will <strong>always</strong> take the wrong path if you take your hands off the wheel. Cursor lets me be in charge and play director while the AI does the heavy lifting and saves me from hours of Googling and digging through 10-year-old questions on Stack Overflow. Especially with the command line stuff. I could not have knocked out Decapod in two weeks without it. But it couldn't have built Decapod at all without me.</p>
</li>
<li><p>✅ Another AI bestie to help you dream, plot, and plan. Cursor is great, but very utilitarian. I always have a session open with a running commentary about my work. I'm constantly feeding it context and leaning on it to get a fresh perspective and solve more esoteric issues, like debugging n8n flow problems, for example. I use Claude for absolutely everything. It has the most natural conversational flow, it's good at taking meta instructions regarding its behavior, and it always has an eye on accuracy – very reliable.</p>
</li>
</ul>
<h2 id="heading-assembling-the-dream-team-ikea-style">Assembling the Dream Team – Ikea Style</h2>
<p>Here are the pieces and parts you'll find in your Dekkaplonkën Ikea flat pack (the GitHub repo).</p>
<ol>
<li>Four Docker containers containing five services with docker-compose files. Just heat and serve.</li>
</ol>
<ul>
<li><p>Infrastructure: Caddy for routing and SSL certificates for https security.</p>
</li>
<li><p>Infrastructure: Postgres for all your data needs.</p>
</li>
<li><p>MinIO: An S3 compatible file storage system.</p>
</li>
<li><p>n8n: The ultimate automation tool.</p>
</li>
<li><p>Open WebUI: The ultimate AI chat interface.</p>
</li>
<li><p>SQL tables</p>
<ul>
<li><p>A table for the decapod state.</p>
</li>
<li><p>A table for jobs, tasks, and tool chat history.</p>
</li>
</ul>
</li>
<li><p>S3 Files and Folders – Agent Templates</p>
<ul>
<li><p>Four starter skills (two actually implemented in n8n).</p>
</li>
<li><p>Two instructional files, including the persona and skill definitions.</p>
</li>
</ul>
</li>
<li><p>n8n Workflows (6,889 lines of pure JSON)</p>
<ul>
<li><p>API Middleware: The entry and exit point that manages the session and loops.</p>
</li>
<li><p>AI Tool Router: Executes your agent's tool requests.</p>
</li>
<li><p>Construct Message History: Injects instructions into your agent's chat history.</p>
</li>
<li><p>Get Job Queue: A one-off database call that gets active jobs ordered by priority and creation date (First In First Out).</p>
</li>
<li><p>Utility Workbench: A place for testing and managing your flows. Currently contains a Skill assembly jig.</p>
</li>
<li><p>Worker: Loops over job queues, talking to the agent and calling the tool router with its responses.</p>
</li>
<li><p>A write-file skill and a research-recipes skill.</p>
</li>
<li><p>A couple more placeholders. (Decapod is an MVP)</p>
</li>
</ul>
</li>
<li><p>Also</p>
<ul>
<li><p>A Docker cheatsheet.</p>
</li>
<li><p>A script to generate agents from the template.</p>
</li>
<li><p>A destructive script to upload local agent files to your S3 account by overwriting existing files. Good for dev. Bad if you let your agent start modding their own instructions.</p>
</li>
<li><p>Scripts to start and stop all Docker containers at once.</p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-accessing-your-vps-with-cursor-and-ssh">Accessing Your VPS With Cursor and SSH</h3>
<p>SSH is the standard way to access any server and has been forever. But working through a terminal can be slow and plodding. Fortunately, there's a better way.</p>
<p>Connect to the server with Cursor, VS Code, Antigravity, or whatever you use. This gives you:</p>
<ul>
<li><p>Multiple terminals to access the remote server.</p>
</li>
<li><p>The ability to view localhost servers as if they were on your own machine via port forwarding.</p>
</li>
<li><p>Drag and drop folder and file management.</p>
</li>
<li><p>No more Nano, Vim, or Emacs (unless you want to).</p>
</li>
<li><p>And the best part! Cursor can do all the remote file system work for you, including troubleshooting servers and containers, writing scripts for automating common tasks, and helping you hash out actionable plans.</p>
</li>
<li><p>(Cursor can also connect to your Decapod!)</p>
</li>
</ul>
<p>Every VPS provider will have their own way of managing SSH access. They usually make adding them part of the sign up process.</p>
<p>Generating and managing keys is a pretty well-paved path and I won't go over it. It's a good job for Cursor, if you need help.</p>
<p>However! I use Bitwarden for SSH key generation and management. They still need to be stored locally for tools on your computer to access. But it's nice to have them in a single secure location.</p>
<p>VS Code requires an extra plugin to access a remote server. Cursor comes with it preinstalled. Just click <code>Connect via SSH</code>, set up your connection, and you're good to go.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/36c686e7-2d7b-43a9-9078-98a5dd2af5be.png" alt="The cursor launch screen with a button to &quot;Connect via SSH.&quot;" style="display:block;margin:0 auto" width="428" height="212" loading="lazy">

<p>📝 Side note: I was on the paid plan when I started, I swear. I tend to switch services a lot as new models are released and I discover different tools and options. But I only ever pay for 2 or 3 at a time.</p>
<p>I got about halfway through this article when Cursor expired. But I'm trying the new Gemini 3 models and switched to Antigravity mid-flight rather than re-up cursor.</p>
<h3 id="heading-installing-and-configuring-the-docker-containers">Installing and Configuring the Docker Containers</h3>
<p>Finally! After a novella's worth of lead-up, we, at long last, get to the actual installation. That will be shared in the next article – have a good night! Just kidding, please put down the brick.</p>
<p>Once you've SSHed in to a VPS, a Raspberry Pi with Ubuntu, or a Virtual Machine, you're ready to get started. I'm going to assume you know how to install tools like Docker and Node on your system and not go into a lot of detail. Ask your friendly neighborhood AI for help if you get stuck.</p>
<p>💡 Important! If you haven't already, get your domain name and open up the DNS page. You'll want to redirect "A" records to your IP for each relevant service.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/c381b917-a731-41bc-a62c-923646c87ae3.png" alt="DNS records for four subdomains." style="display:block;margin:0 auto" width="565" height="187" loading="lazy">

<p>Start by cloning the Decapod repo.</p>
<pre><code class="language-shell">git clone https://github.com/leetheguy/decapod.git
</code></pre>
<p><code>cd decapod</code> and create your Docker network.</p>
<pre><code class="language-shell">docker network create web
</code></pre>
<p>Now we're going to go into each of the four Docker folders, configure them, and fire them up, starting with infrastructure.</p>
<p><code>cd infrastructure</code> <code>cp .env.example .env</code></p>
<p>Alternatively, you can move the files to rename them or just click on the file in the UI and <code>F2</code> to rename it. Whatever floats your goat 🐐.</p>
<p>Now edit the new <code>.env</code> file. You can get the data folder path by clicking on the infrastructure folder and <code>Ctrl/Cmd+Alt+C</code>. The rest is up to you. I used Bitwarden to generate a password here.</p>
<p>Next, copy the Caddyfile template into its own file.</p>
<p><code>cp caddy_config/Caddyfile.template caddy_config/Caddyfile</code></p>
<p>And start the Docker container with <code>docker compose up -d.</code></p>
<p>Back out of infrastructure and into <code>minio</code>. Same again with the <code>.env</code> – copy and configure. Make sure the URLs match your domain.</p>
<p>Once more for <code>n8n</code> and then again for <code>openwebui</code>.</p>
<p>OWUI config comes from the <code>infrastructure</code> and <code>minio</code> <code>.env</code> files:</p>
<ul>
<li><p>S3_ACCESS_KEY_ID=minio_admin</p>
</li>
<li><p>S3_SECRET_ACCESS_KEY=minio_password</p>
</li>
<li><p>S3_BUCKET_NAME=decapod</p>
</li>
<li><p>MINIO_ROOT_USER=minio_admin</p>
</li>
<li><p>MINIO_ROOT_PASSWORD=minio_password</p>
</li>
<li><p>POSTGRES_DB=postgres</p>
</li>
<li><p>POSTGRES_USER=postgres</p>
</li>
<li><p>POSTGRES_PASSWORD=postgres_password</p>
</li>
</ul>
<p>📝 Note! OWUI may take a moment or two to start. Go grab some water and it should be up by the time you get back.</p>
<h2 id="heading-configuration-and-wiring">Configuration and Wiring</h2>
<p>Roll up your sleeves! This is where we get up to our elbows in pieces and parts.</p>
<p>If everything went to plan, you should now have all five services up and running. You can confirm the containers are live with <code>docker ps</code>. You can check that they're actually properly connected by visiting s3, OWUI, and n8n.your-domain.com.</p>
<p>Create accounts for all three and sign in to each.</p>
<p>⚡️ Important! Get your n8n license key! It's free and gives you access to all community features. You'll be severely limited without it. Activate it under Usage and plan in the settings.</p>
<h3 id="heading-initiate-the-database">Initiate the Database</h3>
<p>Decapod only needs two data tables. You can add them from the command line. But I like pgAdmin.</p>
<p>Connect to your Postgres database in the usual way. But you'll need your server's IP for the host name instead of postgres (which you use to connect services inside of the Docker network) since pgAdmin isn't in your Docker network.</p>
<p>You'll find your SQL files in <code>components/pgsql_tables</code>. Create a decapod database and add both of the SQL files to it. A default <code>decapod_state</code> table record will be automatically generated when running the SQL.</p>
<p>In pgAdmin:</p>
<ul>
<li><p>Open the decapod server.</p>
</li>
<li><p>Create a decapod database by right-clicking on databases.</p>
</li>
<li><p>Select the new database.</p>
</li>
<li><p>Click the query tool button at the top of the explorer.</p>
</li>
<li><p>Copy and paste the decapod_state table into the query and run it with F5.</p>
</li>
<li><p>Clear the query, paste in job_queue, run it.</p>
</li>
</ul>
<p>Or ask Cursor or an AI bestie for help if you want to go pure command line.</p>
<h3 id="heading-a-little-minio">A Little MinIO</h3>
<p>Next up, you'll be adding your agent's instructions and persona files to your private S3 service. Start by visiting your MinIO server and adding a decapod bucket.</p>
<p>In <code>components/S3_structure/agents/</code>, you'll find a template for your agents. (I have the intention of making Decapod a multi-agent tool in a future release.) The template is meant to be copied to a new agent of your choice. But if you choose something other than Decapod, you'll need to update the state table.</p>
<p>You can do it manually if you wish. Copy the folder to match the new agent's name and update the <code>definitions/skills.yaml</code> file to include all the skills you want your agent to have. The name and description should exactly match what's found at the top of each skill file.</p>
<p>Alternatively, I vibe coded a script to make it a little easier. It's in the scripts folder and you'll need to install the <code>inquirer</code> Node module to use it. Run <code>cd scripts</code> and <code>create-agent.mjs</code> to use it.</p>
<p>You also need to make sure that the files and folder structure in your MinIO match those in <code>S3_structure</code>. Start by creating a bucket called decapod in your drive. Then upload the files from <code>S3_structure</code> into your bucket.</p>
<p>But that's easier said than done because they're on a remote server. And if you used the visual interface, you'd have to download them to your local machine first. So I made another script – <code>upload_S3_structure.sh</code>.</p>
<p>That script is strictly meant for dev purposes. It's absolute and destructive. Just a heavy mallet. So if you want to surgically alter your MinIO, do not use it! Remember kids: mallets and brain surgery don't mix.</p>
<p>Once your agent files are in place, you can let your agents edit them, Open Claw style, or you can edit them yourself. But MinIO doesn't give you much of anything in the way of features for their UI.</p>
<p>For a better experience, I'd recommend <a href="https://web.s3drive.app/">S3Drive</a>. When you go to sign up, look for the connect button towards the bottom to connect to your own MinIO endpoint.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/d3b5faa7-8e5d-4a35-84c9-0d97ea73d96c.png" alt="The S3Drive setup interface." style="display:block;margin:0 auto" width="464" height="330" loading="lazy">

<p>S3Drive will let you edit your files in place after you've uploaded them. This is good for quick fixes or copying and pasting sections without a complete wipe.</p>
<h3 id="heading-adding-the-workflows">Adding the Workflows</h3>
<p>You'll find most of what makes Decapod Decapod in the components folder. And the heart of that is in n8n_workflows.</p>
<p>You can manually import those workflows one at a time and go over each one to make sure they're safe and sound. Or you can use the n8n CLI inside of the Docker container and save yourself some tedium.</p>
<p>These commands move the workflows to the Docker container, import them with the n8n CLI, and then remove them from the tmp directory.</p>
<pre><code class="language-shell">docker cp ./components/n8n_workflows n8n:/tmp/workflows

docker exec -u node n8n n8n import:workflow --input=/tmp/workflows --separate
docker exec -u node n8n n8n import:workflow --input=/tmp/workflows/skills --separate

docker exec -u root n8n rm -rf /tmp/workflows
</code></pre>
<p>Now, you should see the 10 workflows in n8n. I'd recommend drag-and-dropping the main workflows to a dedicated decapod folder and the two skills to decapod/skills, just to keep things tidy. But they reference each other by id, so do what you want.</p>
<h3 id="heading-getting-started-with-n8n">Getting Started With n8n</h3>
<p>Now would be a good time to start exploring the workflows in your n8n UI Personal tab. If you sort them by name, the main file will be on top. Crack it open and see it's not too intense, and it's self-documented. Blue for notes, Green for sub-workflows, and Red for nodes that require your credentials.</p>
<p>I'd recommend reading the notes and thoroughly exploring the sub-workflows to help you understand Decapod. It's your tool now! Create credentials as you go.</p>
<p>Because we're using a Docker network, creating credentials and connecting your services to each other couldn't be easier.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/de946994-8b01-436e-9a3b-aa79a46a0073.png" alt="The credentials page for an n8n Postgres connection." style="display:block;margin:0 auto" width="735" height="393" loading="lazy">

<p>The standard to connect all of your services is to reference them by <code>name:port</code>. Because the Postgres credential has its own port field, you can just set it to Postgres. Port should be 5432.</p>
<p>📝 Note! All credential details, like your container names, ports, and passwords, can be found in your docker-compose and .env files.</p>
<p>For MinIO:</p>
<ul>
<li><p>Endpoint: <code>http://minio:9000</code></p>
</li>
<li><p>Force Path Style: Enabled! Important for MinIO.</p>
</li>
</ul>
<p>API Connections to OpenRouter:</p>
<ul>
<li><p>choose: Authentication -&gt; Predefined Credential Type</p>
</li>
<li><p>then: Credential Type -&gt; OpenRouter</p>
</li>
<li><p>Now just paste your API key from <a href="https://openrouter.ai/settings/keys">OpenRouter</a>.</p>
</li>
</ul>
<p>n8n – (meta access to your workflow):</p>
<ul>
<li><p>In a new tab, go to n8n Settings -&gt; n8n API.</p>
</li>
<li><p>Turn off expiration if you like.</p>
</li>
<li><p>Copy your key.</p>
</li>
<li><p>Paste it in the field.</p>
</li>
<li><p>Base URL: <code>http://n8n:5678/api/v1</code></p>
</li>
</ul>
<p>Once you've created credentials, you can reuse them for every relevant node that uses the same credential. Just select it from the dropdown.</p>
<p>💡 Tip! It may help to remove the red sticky notes as you add credentials. And don't forget the skills! I didn't sticky note them at all.</p>
<p>As a final step, make sure your n8n workflows are published in the following order:</p>
<ul>
<li><p>construct message history</p>
</li>
<li><p>get job queue</p>
</li>
<li><p>hitl yes/no</p>
</li>
<li><p>tool router</p>
</li>
<li><p>worker</p>
</li>
<li><p>middleware</p>
</li>
<li><p>and the two skills</p>
</li>
</ul>
<p>💡 Tip! Always make sure your n8n workflows are in a published state with a green dot before calling them. Otherwise, you'll be calling an outdated version.</p>
<h3 id="heading-now-get-owui-to-talk-to-decapod">Now, Get OWUI to Talk to Decapod</h3>
<p>OWUI is built for teams, so you have admin settings and personal settings. You'll want to edit the admin settings by clicking on the profile circle in the lower-left-hand corner, then Admin Panel -&gt; Settings -&gt; Connections.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/10840f19-1cbb-41e9-b066-e4c0033c0244.png" alt="Open WebUI's connections config page." style="display:block;margin:0 auto" width="1275" height="298" loading="lazy">

<p>From there:</p>
<ul>
<li><p>Ollama API Disabled: Just keeping things tidy.</p>
</li>
<li><p>Configure the OpenAI link by clicking on the gear and delete that too.</p>
</li>
<li><p>Direct Connections: Enabled</p>
</li>
<li><p>Cache Base Model List: Enabled Now add your Decapod connector with the plus button.</p>
</li>
<li><p>URL: <a href="http://n8n:5678/webhook/v1/decapod">http://n8n:5678/webhook/v1/decapod</a> (Click the cycle icon to confirm your connection.)</p>
</li>
<li><p>Auth: none (it's all in the same Docker network, so it's fine for now. You can add a password for production.)</p>
</li>
<li><p>Prefix ID: decapod (If you do decide to use OpenAI, Hugging Face, or whatever else, this will help distinguish the model hosts.)</p>
</li>
</ul>
<p>That's it. Save and go to the Models tab. Decapod passes OpenRouter models straight through. So if you see hundreds of models, take a victory lap! That means that Decapod is working, live, accepting requests, and you've even properly done your certifications (at least for OpenRouter).</p>
<p>Now create a new chat session and pick a model. I like Claude Haiku 4.5. Fast, cheap, and good. Pick three. I did all of my Decapod dev with it in the saddle, so I know it works. And 3.5 million tokens towards testing iterations cost me \(4, so I know it's reasonable. Alternatively, Kimi K2.5 will likely work and would be even a little bit cheaper. I burned through 4.7 million tokens installing a Docker container in Open Claw with Kimi for about \)3.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/8773faab-b7bd-47fe-90c7-0ed4aa0cbbed.png" alt="A successful communication between Open WebUI and Decapod." style="display:block;margin-left:auto" width="1005" height="487" loading="lazy">

<p>Time to say hello to your little friend! Haiku is fast. So if it takes more than a few seconds to respond, something could be borked in your n8n flow. It happened to me as I was writing this article. I had some issues with both Postgres and MinIO.</p>
<p>💡 Tip: If the agent does get hung, it's easier to resend the message than stop and try again.</p>
<h3 id="heading-there-was-supposed-to-be-an-earth-shattering-kaboom">There Was Supposed to Be an Earth Shattering Kaboom</h3>
<p>So, your agent really wants to talk to you, but all you have is a pulsating dot. It's likely that something got misconfigured in n8n.</p>
<p>You can debug n8n by going to the middleware workflow and selecting <code>executions</code> from the top tab bar. If there's an error on the left list, look for a message in the lower right.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/af5f6ea5-ad99-45e0-88c6-49ccc479fac1.png" alt="An example n8n error message." style="display:block;margin:0 auto" width="320" height="125" loading="lazy">

<p>This was when I had some database config issues and it couldn't find the state table.</p>
<p>Some sub-workflows may fail quietly. You can trace flow from the webhook entry point to the error. All successful nodes will light up green. The bad node will be red. Drill down, check executions, and repeat for each sub-workflow.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/935b1bcc-f03d-452e-a67d-da00e2265d39.png" alt="An portion of an n8n workflow showing a node that threw an error." style="display:block;margin:0 auto" width="839" height="281" loading="lazy">

<p>When you find the culprit – the actual bad node in the bad execution – select "copy to editor" in the upper-right-hand corner. That will freeze the workflow to that state. Open the node, fix the credential or whatever, and click <code>Execute Step</code> to see if it's fixed.</p>
<p>Remember: after every change, always always always publish your update. Otherwise, n8n won't actually use the latest fixed version of your workflow.</p>
<p>Once you've successfully debugged your Decapod, make sure that you clean out the loose unfinished jobs in the job_queue table with pgAdmin or whatever. Otherwise, your agent will try to complete each of them before finishing the next job.</p>
<h2 id="heading-the-ever-present-hello-world">The Ever-Present "Hello World"</h2>
<p>OK! Now for the moment of truth. You got your agent to say hello back. That was the easy part because it didn't need to do any work or use any tools.</p>
<p>I set you up with two skills to put it to the test: write-file and research-recipes. The recipes skill connects your bot to a free recipe API (no key needed). It's not just pulling recipes out of training data.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/84eefbe8-ad4d-44fb-ae19-3291d85fe0e9.png" alt="A successful request to Decapod requiring tool use." style="display:block;margin:0 auto" width="1011" height="400" loading="lazy">

<p>Try this prompt: Would you please look up pizza recipes and save them to a file?</p>
<p>If all of your credentials are properly configured, you should get what you asked for. Open up MinIO or S3Drive and look in <code>/agents/decapod/documents</code> for the file.</p>
<h2 id="heading-into-the-future">Into the Future!</h2>
<p>I know that was a lot! (At least it felt like a lot from my end.) I hope it wasn't too painful. And look at the bright side: you just got a crash course on some really powerful technology. And if you made it through, that's a major accomplishment! The hard part is behind you. Now comes the fun.</p>
<h3 id="heading-a-work-in-progress">A Work in Progress</h3>
<p>I'll be honest. I just wanted to get Decapod out fast to prove how doable a personal agent is while Open Claw is still hot. Anyone can build their own Agentic AI with little or no code. And you don't have to settle for painful UI and poor security. You can have it all.</p>
<p>But, as I've said, Decapod is still an MVP. Complete and functional, but feature light. And I was stressing about that a little bit. I wanted multiple agents and more skills for the early adopters.</p>
<p>Then it hit me. Duh! You already have everything you need with n8n.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/466cc6ab-f038-4728-9fcf-06d9f631f75c.png" alt="An example of chatting with an n8n agent that has internet access." style="display:block;margin:0 auto" width="769" height="540" loading="lazy">

<p>You can add an n8n agent node, connect it to a model and an MCP server, and have a sub-agent ready to go in minutes. Then have your agent produce a skill sheet to contact the sub-agent.</p>
<h3 id="heading-adding-your-own-skills-limitless-potential">Adding Your Own Skills – Limitless Potential</h3>
<p>Let's create a dead simple n8n agent to search the web. Then we'll add that to Decapod as a new skill.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/95ffe965-3180-44fb-8ac2-7835b3931224.png" alt="A request for Decapod to create a new skill sheet." style="display:block;margin:0 auto" width="857" height="535" loading="lazy">

<p>In this image I used the prompt:</p>
<blockquote>
<p>Thank you so much! Next up, I want to give you web search access via a sub-agent. So your web search skill wouldn't directly search the web, but would instead call a simple agent to do the search for you.</p>
<p>Would you please create a web-search.md skill for your future self to use? The only required field should be prompt.</p>
</blockquote>
<p>The agent's file folder is sandboxed by default, so the agent's <code>skills/web-search.md</code> is actually in the agent's private <code>documents</code> storage. I moved it to the actual skills folder and updated my agent's skills.yaml file with the new skill.</p>
<p>Now I'll create a new n8n skill workflow in <code>decapod/skills/</code>.</p>
<p>⚡️ Important! Your n8n skill workflow name must match the skill name exactly. So, <a href="http://web-search.md">web-search.md</a> would be a workflow called web-search. Decapod uses the name to look for the skill so it can be hot loaded without a secondary router.</p>
<p>The n8n screenshot above was pretty much exactly the whole thing. Try rebuilding it yourself. I used chat input to make sure it was working with n8n's chat interface. And I used the <a href="https://www.pulsemcp.com/servers/exa">Exa Web Search MCP</a> as the search tool. I used Haiku as the model, but an even simpler model would have likely been just fine. OpenRouter has a number of free models with tool abilities that would probably do the trick.</p>
<p>Once you have the workflow operating properly, replace the chat node with a "When Executed by Another Workflow" node with a <code>parameters</code> object as input.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/ddb03570-36d2-4a50-9acb-1a80cf02c11d.png" alt="The configuration of an n8n &quot;When Executed by Another Workflow&quot; node." style="display:block;margin:0 auto" width="444" height="350" loading="lazy">

<p>Next, open up the utility/workbench workflow. This tool will help you turn your web-search workflow into a skill. Work through each node in order, testing the node with "Execute step" button as you go. Doing so will create output data that the next node can use as input data.</p>
<ol>
<li><p>get workflow id from name: Set name to "web-search".</p>
</li>
<li><p>deliver JSON arguments to skill: Set parameters object to { "prompt": "Can I please get a list of a variety of pizza recipes complete with links to their sources?" }; (or whatever matches your skill sheet)</p>
</li>
<li><p>call skill based on workflow id: Should be ready to execute.</p>
</li>
</ol>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/0752c8a8-ab15-4c49-90ee-29e822b90f57.png" alt="an example of a successful n8n call to a sub-workflow." style="display:block;margin:0 auto" width="1106" height="634" loading="lazy">

<p>If your output looks like that, your skill should be ready to go.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/3b13ac1e-1c61-4fce-9896-14d569593ca3.png" alt="Decapod returning search results for dessert pizza recipes." style="display:block;margin:0 auto" width="852" height="383" loading="lazy">

<p>In this image I used the prompt: Alright! I think you're all set. Try doing a search for dessert pizza recipes.</p>
<p>If your agent gives you the following error, make sure that it knows it MUST create a job before it can call the <code>use_skill</code> tool. It should know that from the instructions, but pobody's nerfect. (I'll need to tighten that up.)</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/6e63dc70-3bae-4389-aa68-80a22f6553b6.png" alt="An example response from a Decapod error." style="display:block;margin:0 auto" width="850" height="181" loading="lazy">

<p>Hopefully that was also pretty painless and now your mind is exploding with possibilities like mine is. If you're unconcerned with safety or actively want to invoke Skynet, you can even give your agent a skill to create its own n8n skills with the <code>Create a workflow</code> node. But don't do that.</p>
<h2 id="heading-future-plans">Future Plans</h2>
<p>Here are a few more features I'd like to add:</p>
<ul>
<li><p>/slash commands – You shouldn't have to go into n8n or pgAdmin to see what your agent is doing and manage its job queue.</p>
</li>
<li><p>Streaming responses – I'd like to see what my agent is doing as it's doing it, but streaming is a bit tricky and was beyond the MVP.</p>
</li>
<li><p>Multiple states – With multiple states, you can run multiple agents simultaneously. Or you can have different agents/models for different sessions. For example, you can have a health and fitness session with one agent with its own context window, job queue, and skill set. And you can have another one to help you keep track of your coding education.</p>
</li>
<li><p>It's a bug, not a feature – There are many places where the state and model are hard-coded throughout the app. I also started working on features that didn't pan out and left some dangling nodes. I'd like to clean up the app and actually implement those features.</p>
</li>
</ul>
<p>If you've read this far and are totally all in, I'd love to hear feedback and suggestions for more features. I'd be fascinated to hear about how Decapod is being used. And I'm also happy to answer any questions.</p>
<h2 id="heading-got-questions-meet-captain-finn">Got Questions? Meet Captain Finn!</h2>
<p>Decapod is the culmination of a year spent studying and learning all things AI and automation. It's also the result of 20 years in the world of coding and app development.</p>
<p>I'm currently starting a community for AI Enthusiasts, Automation Inventors, and Systems Thinkers. It will be led by Captain Finn, a retro-futuristic space captain who got stranded without his crew in our time and space. He used AI, automation, and systems thinking to keep the ship working, give himself someone to talk to, and to wake up to the smell of fresh coffee every morning.</p>
<p>And yes, Finn himself is an AI persona, operating from AI-automated systems, like Decapod, that he will be teaching people about.</p>
<p>My goal is to create a welcoming environment for my fellow mad scientists, dreamers, and citizen developers to learn and grow with help from the community and Captain Finn Feldspar himself. I plan to release weekly articles, more tutorials like this, and other tips and tricks.</p>
<p>Whether you want help with Decapod, learning automation, or just want to geek out about the power and future of AI — Captain Finn's Fleet has a place for you.&nbsp;<a href="https://discord.gg/HJtTpBAjQ5">Join here for free.</a></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
