CI/CD in the Era of AI and Platform Engineering: A Deep Dive into Dagger CI (Part 1)

Part 1: The CI/CD Bottleneck Nobody Talks About

Platform Engineering matured how we provision infrastructure. It hasn’t touched how we build and deploy code.

CI/CD was, a decade ago, at the heart of the DevOps revolution. It deeply transformed the way we ship software by providing continuous automation workflows from code to production. It hasn’t changed much since then. And like every tool that has been continuously used for so long, the more years pass, the more the flaws and frustrations seem obvious. The “traditional” way of building CI/CD nowadays often feels cumbersome and rusty.

Among the almost always recurrent flaws that any DevOps engineer has witnessed:

  • Lack of development ergonomics. For most technologies, the only way to really test pipelines is by pushing the code to the server, waiting, and iterating. It’s inefficient and time-consuming. The feedback loop for CI/CD code is worse than for any other code in your organization.

  • YAML sprawl. As soon as pipelines require more logic and complexity, you rapidly end up with thousands of YAML files to maintain across repositories, each slightly different, each owned by whoever touched it last.

  • Opaque failure investigation. When things go wrong in a pipeline, there is no intelligence. Developers sift through thousands of log lines to manually investigate, with no programmatic way to diagnose or recover.

Meanwhile, Infrastructure-as-Code solved provisioning. GitOps solved configuration drift. Internal Developer Platforms gave teams golden paths for spinning up services, databases, and environments. But open any repository in your organization and look at the CI/CD: it’s still bespoke YAML. In a world where platform teams build abstractions for everything else, CI/CD remains the one piece of developer infrastructure that every team reinvents from scratch.

This series explores what a CI/CD pipeline could look like in 2026, one that addresses all three of these flaws, at the intersection of Platform Engineering and AI agents. To do that, we’ll use one of the most exciting tools I’ve seen in the CI/CD landscape in a long time: Dagger. Dagger reimagines pipelines as typed functions written in real programming languages, running inside containers with deterministic caching, portable across any CI engine. But what makes it particularly relevant for this series is that it doesn’t stop at build automation: it has native LLM integration, a composable module system, and an emerging agent ecosystem that lets you go from reusable pipeline modules to AI-powered CI/CD workflows. It’s the right tool to explore what happens when Platform Engineering meets AI agents in the CI/CD space.

We’ll build pipelines as real code, decouple them from the infrastructure they run on, package them as reusable modules, and eventually compose AI agents that handle review, build, and deployment from a single natural language command.

The Series at a Glance

This is a 4-part series. Each part builds on the previous one, progressively transforming how we think about CI/CD:

  1. Part 1 (this article): Pipelines as real code. We replace YAML with typed, testable, locally-runnable pipeline functions using Dagger, and build a complete CI/CD pipeline for an Angular + FastAPI stack deployed to Google Cloud.

  2. Part 2: Decoupling Pipelines from Infrastructure: Same pipeline code, different runners. We explore three options for scaling: GitHub Actions (free tier), managed runners with Depot.dev, and self-hosted Kubernetes runners with ARC, including a Terraform module for production deployments.

  3. Part 3: From Scripts to a Platform: Your CI/CD Module Library: Modules as the unit of CI/CD reuse. We build a two-layer architecture: public daggerverse modules for generic operations (GCP Auth, Cloud Run, Firebase) wrapped by private organizational modules that enforce compliance, security, and conventions.

  4. Part 4: The AI-Native CI/CD Stack: Agents, Modules, and Spec-Driven Development: We leverage the agentic capabilities of Dagger to enhance the developer experience, automate CI failure remediation, and explore what an Internal Developer Factory looks like when built on Spec-Driven Development principles. The pipeline itself stays fast and deterministic, with no LLM in the hot path.

But first: let’s look at the root cause.

The YAML Illusion

YAML-based CI/CD looks simple. A few lines, a run: step, and you have a pipeline. But that simplicity is an illusion. The moment a real-world project needs conditional logic, secret management, reusable workflows, matrix builds, or cross-repository dependencies, every CI platform reaches for its own layer of abstractions on top of YAML.

GitHub Actions has reusable workflows, composite actions, and a marketplace of third-party actions you import by reference. GitLab CI has include:, extends:, and multi-project pipelines. CircleCI has orbs. Azure DevOps has templates and task groups. Each platform reinvented inheritance, composition, and parameterization (poorly) on top of a configuration format that was never designed for it.

The result is a system that is declarative in appearance but imperative in practice. A developer looking at a .github/workflows/deploy.yml that imports three reusable workflows, each with their own if: conditions and secret references, is not reading a simple configuration file. They’re reading a distributed program written in a language with no type system, no IDE support, no debugger, and no way to run it locally.

This is the fundamental problem: CI/CD, as it exists today, is not part of the software factory. It sits outside the development workflow. It can’t be run on a developer’s machine. It can’t be unit tested. It can’t be refactored with confidence. It can’t be debugged step by step. It doesn’t benefit from the tooling, the practices, or the discipline that every other piece of software in the organization does.

And yet it’s the most critical automation in your delivery chain, the one that decides whether code reaches production.

CI/CD as Software

The fix isn’t a better YAML syntax. It’s a paradigm shift: treat CI/CD as an integral part of the software factory.

That means CI/CD primitives (building a container, running tests, publishing an artifact, deploying a service) should be programmatic abstractions. Written in real programming languages. With types, functions, imports, error handling. Runnable on a developer’s laptop. Testable. Debuggable. Versioned alongside the application code. Reviewable in the same pull request.

This is what Dagger does. It decomposes CI/CD operations into typed functions, available through SDKs in Python, Go, and TypeScript. A pipeline is not a configuration file interpreted by a remote server. It’s code that runs inside containers, with content-addressed caching at every step.

A note on language choice — Throughout this series, we’ll use Python for all our examples. But everything we write here works identically in Go, TypeScript, or Java. Dagger’s SDKs expose the same API surface and the same types across all supported languages — the pipeline behavior is determined by the engine, not the SDK. Pick the language your team is most comfortable with.

from typing import Annotated

import dagger
from dagger import Doc, dag, function, object_type


@object_type
class MyPipeline:
    @function
    async def build(
        self,
        source: Annotated[dagger.Directory, Doc("Application source code")],
    ) -> dagger.Container:
        """Build the application container."""
        return (
            dag.container()
            .from_("python:3.12-slim")
            .with_directory("/app", source)
            .with_workdir("/app")
            .with_exec(["pip", "install", "-r", "requirements.txt"])
            .with_entrypoint(["python", "main.py"])
        )

This is a typed, composable function that:

  • Runs on your laptop with dagger call build --source=.
  • Runs in GitHub Actions with the exact same command
  • Runs in GitLab CI with the exact same command
  • Can be debugged, unit tested, and refactored like any other code

No YAML translation. No platform-specific quirks. The same code, the same command, everywhere.

The shift is subtle but significant. CI/CD is no longer a separate discipline with its own language, its own debugging workflow, and its own specialists. It’s software. Developers can read it, understand it, modify it, and run it, the same way they do with application code. That’s the self-service promise of Platform Engineering applied to the one area it hadn’t reached yet.

Three Properties That Make It Work

When CI/CD becomes software, three properties emerge that YAML pipelines fundamentally cannot provide.

1. Container-Based Execution

Every operation happens inside a container. When you write:

dag.container().from_("python:3.12-slim").with_exec(["pip", "install", "pytest"])

You’re building a container execution graph. The engine takes this graph and executes it efficiently. Your pipeline is reproducible (same inputs, same outputs), isolated (no “works on my machine”), and portable (containers run anywhere).

This is what enables local execution. The same container graph that runs on a CI runner runs on your laptop. No emulation, no mocking, just the real thing. A developer can iterate on a pipeline the same way they iterate on application code: change, run, observe, repeat. The feedback loop goes from “push and wait 10 minutes” to “run and see in seconds.”

2. Content-Addressed Caching

Every operation is cached based on the hash of its inputs:

@function
async def test(
    self,
    source: Annotated[dagger.Directory, Doc("Application source code")],
) -> str:
    """Run tests with intelligent caching."""
    return await (
        dag.container()
        .from_("python:3.12-slim")
        .with_directory("/app", source)       # Cached if source unchanged
        .with_exec(["pip", "install", "-r", "requirements.txt"])  # Cached if requirements unchanged
        .with_exec(["pytest"])                 # Only re-runs if dependencies changed
        .stdout()
    )

Change a test file? Only the test execution re-runs. Change requirements.txt? Dependencies reinstall, but the base image stays cached. This is content-addressed: Dagger hashes the actual content, not timestamps.

Because it’s deterministic, caching works identically on your laptop and in CI. A developer running dagger call test --source=. after changing one test file gets the same instant feedback a CI runner would, with no cold start and no full rebuild.

3. A Module System

Modules are reusable pipeline components that can be published and shared:

# Use a published module
dagger call -m github.com/purpleclay/daggerverse/[email protected] build --source=.

# Or reference your own
dagger call -m ./.dagger build --source=.

Modules have typed interfaces, documentation, and versioning. They compose:

@function
async def deploy(
    self,
    source: Annotated[dagger.Directory, Doc("Application source")],
) -> str:
    # Authenticate to GCP
    gcp = dag.gcp_auth().from_service_account_key(key=sa_key, project=project)

    # Publish to Artifact Registry
    image_uri = await dag.gcp_artifact_registry(gcp).publish(
        container=await self.build(source),
        repository="my-repo", image="my-app", tag="latest",
    )

    # Deploy to Cloud Run
    return await dag.gcp_cloud_run(gcp).service().deploy(
        name="my-service", image=image_uri, region="us-central1",
    )

This is the Platform Engineering pattern applied to CI/CD. A platform team builds and maintains modules (gcp-auth, artifact-registry, cloud-run) the same way they’d maintain Terraform modules or Helm charts. Developers consume them as typed dependencies. They don’t need to know how GCP authentication works internally; they call dag.gcp_auth() and get an authenticated container. Self-service, with guardrails.

Putting It Into Practice

Enough theory. For the rest of this series, we’re going to simulate, as closely as we can, a production deployment. Not a toy example with a single endpoint and a hello world container, but a realistic two-component product with a frontend, a backend, authentication between them, and deployment to a real cloud provider.

The goal is a deep dive: from writing the first pipeline function on your laptop, all the way to a multi-agent CI/CD system that reviews, builds, and deploys both apps from a single natural language command.

Prerequisites

To follow along, you’ll need three things set up:

  1. Dagger CLI: Install the Dagger engine and CLI on your machine. This is what runs your pipelines locally and in CI.
    → Install Dagger

  2. A GitHub account: We’ll use GitHub as our code server and CI runner (via GitHub Actions). The companion repository with all example code is public.
    → Create a GitHub account if you don’t have one

  3. A Google Cloud account with a project: GCP is our deployment target throughout the series. You’ll need a project with billing enabled (the free tier is sufficient for everything we do here).
    → Create a GCP account
    → Create a GCP project

Required Google APIs — Enable these APIs on your GCP project before deploying. You can do it from the API Library or via gcloud:

gcloud services enable 
  run.googleapis.com 
  artifactregistry.googleapis.com 
  firebasehosting.googleapis.com 
  firebase.googleapis.com 
  iam.googleapis.com 
  cloudresourcemanager.googleapis.com
  • Cloud Run API (run.googleapis.com) — deploy the FastAPI backend
  • Artifact Registry API (artifactregistry.googleapis.com) — store container images
  • Firebase Hosting API (firebasehosting.googleapis.com) — host the Angular frontend
  • Firebase API (firebase.googleapis.com) — Firebase Anonymous Auth for the frontend
  • IAM API (iam.googleapis.com) — manage service accounts and OIDC federation
  • Cloud Resource Manager API (cloudresourcemanager.googleapis.com) — project-level operations

You’ll also want Docker installed locally (Dagger uses it under the hood), and Python 3.12+ and Node.js 20+ for the example apps themselves.

The Product: Angular Frontend + FastAPI Backend

Our product is two apps that talk to each other:

  • Angular 21 SPA on Firebase Hosting: displays items from the API, authenticates via Firebase Anonymous Auth
  • FastAPI REST API on Cloud Run: serves items, validates Firebase ID tokens from the frontend

The frontend authenticates anonymously with Firebase, gets an ID token, and sends it as a Bearer header to the backend. The backend validates the token using google-auth. This is a realistic pattern for any Firebase + Cloud Run stack.

Initializing the Module

Clone the companion repository:

git clone https://github.com/telchak/dagger-ci-demo.git
cd dagger-ci-demo

We have a monorepo with backend/ and frontend/. Rather than creating a separate Dagger module for each app, we’ll initialize a single module at the repository root. This gives us one place to define pipeline functions for the entire product:

dagger init --sdk=python --name=dagger-ci-demo

This creates a .dagger/ directory (Dagger’s convention, like .github/ for GitHub Actions) containing a dagger.json manifest, a pyproject.toml, and a source scaffold where we’ll write our pipeline functions.

Key Concepts: Types, Functions, and Chaining

Before looking at the code, let’s clarify three core Dagger concepts (docs):

Types. The Dagger API provides specialized types that represent CI/CD primitives. The ones we’ll use most:

  • Container: an OCI container image with its filesystem and configuration
  • Directory: a filesystem tree (your source code, build output, etc.)
  • File: a single file artifact
  • Secret: sensitive data (API keys, credentials) that Dagger never logs or caches
  • Service: a running network service (for integration testing, local dev, etc.)

These types are the building blocks. They replace the implicit, untyped operations of YAML pipelines with explicit, typed abstractions.


Image generated with Google’s Gemini Imagen “Nano Banana Pro”

Functions. A Dagger module exposes functions, decorated with @function, that accept these types as parameters and return them as outputs. Functions are the unit of work. Each one is a self-contained operation: build a container, run tests, publish an image.

Chaining. Each Dagger type comes with its own methods, and these methods return the same (or related) types. This enables chaining: you pass the output of one operation directly into the next, building up a pipeline step by step. When you write dag.container().from_("python:3.12-slim").with_exec(["pip", "install", "..."]), you’re chaining three operations on a Container type. Dagger evaluates this chain lazily: nothing executes until a terminal operation (like .stdout() or .publish()) triggers the full graph.

The Pipeline: Backend and Frontend

Here’s what we’ll write at .dagger/src/dagger_ci_demo/main.py, a single class with functions for both apps:

"""CI/CD pipeline for the dagger-ci-demo product."""

from typing import Annotated

import dagger
from dagger import Doc, dag, function, object_type


# @object_type marks this class as a Dagger module.
# Its public methods become callable pipeline functions.
@object_type
class DaggerCiDemo:
    """Pipeline for the Angular + FastAPI product."""

    # --- Backend (FastAPI) ---

    @function
    async def build_backend(
        self,
        source: Annotated[dagger.Directory, Doc("Backend source directory")],
    ) -> dagger.Container:
        """Build the FastAPI backend container.

        Returns a Container — the core Dagger type representing an OCI image.
        The caller can publish it, export it, or chain further operations on it.
        """
        return (
            dag.container()
            # Start from a base image — returns a Container
            .from_("python:3.12-slim")
            # Set the working directory inside the container
            .with_workdir("/app")
            # Copy only requirements.txt first — this layer is cached
            # as long as requirements.txt doesn't change, even if
            # source code changes. This is the Docker cache optimization
            # pattern, expressed as a function chain.
            .with_file("/app/requirements.txt", source.file("requirements.txt"))
            # Install dependencies — cached if requirements.txt unchanged
            .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
            # Copy application source — only this layer and below re-run
            # when source code changes
            .with_directory("/app/src", source.directory("src"))
            # Configure the container for Cloud Run
            .with_env_variable("PORT", "8080")
            .with_exposed_port(8080)
            .with_entrypoint([
                "uvicorn", "src.main:app",
                "--host", "0.0.0.0", "--port", "8080", "--workers", "2",
            ])
        )

    @function
    async def test_backend(
        self,
        source: Annotated[dagger.Directory, Doc("Backend source directory")],
    ) -> str:
        """Run the backend test suite.

        Returns a string (stdout) — a terminal type that triggers execution.
        Everything before .stdout() is lazily evaluated; Dagger only runs
        the container when it needs the output.
        """
        return await (
            dag.container()
            .from_("python:3.12-slim")
            .with_workdir("/app")
            # Mount the full source directory (tests need everything)
            .with_directory("/app", source)
            .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
            # Run pytest — if this command fails, the function raises an error
            .with_exec(["pytest", "-v", "tests/"])
            # .stdout() is the terminal operation: it triggers execution
            # of the entire chain and returns the command's output as a string
            .stdout()
        )

    # --- Frontend (Angular) ---

    @function
    async def build_frontend(
        self,
        source: Annotated[dagger.Directory, Doc("Frontend source directory")],
    ) -> dagger.Directory:
        """Build the Angular frontend for production.

        Returns a Directory — the build output (dist/) that can be
        deployed to Firebase Hosting, exported to the host filesystem,
        or passed to another function.
        """
        return (
            dag.container()
            .from_("node:20-slim")
            .with_workdir("/app")
            # Copy package files first for dependency caching
            .with_file("/app/package.json", source.file("package.json"))
            .with_file("/app/package-lock.json", source.file("package-lock.json"))
            # npm ci installs exact versions from lockfile — cached if
            # package files unchanged
            .with_exec(["npm", "ci"])
            # Copy the rest of the source
            .with_directory("/app/src", source.directory("src"))
            .with_file("/app/angular.json", source.file("angular.json"))
            .with_file("/app/tsconfig.json", source.file("tsconfig.json"))
            .with_file("/app/tsconfig.app.json", source.file("tsconfig.app.json"))
            # Build for production — Angular CLI outputs to dist/
            .with_exec(["npx", "ng", "build", "--configuration=production"])
            # Extract just the build output directory.
            # .directory() returns a Directory type — stripping away the
            # container and keeping only the filesystem artifact we need.
            .directory("/app/dist/angular-frontend/browser")
        )

    @function
    async def test_frontend(
        self,
        source: Annotated[dagger.Directory, Doc("Frontend source directory")],
    ) -> str:
        """Run the Angular test suite."""
        return await (
            dag.container()
            .from_("node:20-slim")
            .with_workdir("/app")
            .with_directory("/app", source)
            .with_exec(["npm", "ci"])
            .with_exec(["npx", "ng", "test"])
            .stdout()
        )

Let’s unpack what’s going on here.

The class DaggerCiDemo is a module, a collection of functions packaged together. The @object_type decorator registers it with the Dagger engine, and every @function method becomes a callable operation, both from the CLI and from other modules.

Each function takes a Directory as input, Dagger’s type for a filesystem tree. When you pass --source=./backend from the CLI, Dagger packages that directory and hands it to the function as a typed Directory object. No string paths, no glob patterns, just a real, content-addressed filesystem reference.

The function bodies are chains of operations on Dagger types. dag.container().from_("python:3.12-slim") creates a Container from a base image. Each .with_*() call returns a new Container with the modification applied. Nothing executes yet. Dagger builds a directed acyclic graph (DAG) of operations. Execution only happens when a terminal operation like .stdout() or .publish() forces the engine to resolve the graph.

This is why caching works so well. Dagger hashes each node in the graph based on its inputs. If requirements.txt hasn’t changed, the pip install step is a cache hit, instantly. If only a source file changed, only the layers after .with_directory("/app/src", ...) re-execute. Same content-addressed logic, on your laptop and in CI.

Notice how build_backend returns a Container while build_frontend returns a Directory. The return type determines what the caller can do next. A Container can be published to a registry, started as a service, or chained further. A Directory can be deployed to static hosting, exported, or mounted into another container. Types guide composition. They make it clear what flows where.

Run It Locally

With Dagger installed (see prerequisites above), let’s explore the module from the repository root.

Discovering Functions

Before running anything, you can inspect what the module exposes:

dagger functions
Name             Description
build-backend    Build the FastAPI backend container.
build-frontend   Build the Angular frontend for production.
test-backend     Run the backend test suite.
test-frontend    Run the Angular test suite.

This is auto-generated. Dagger reads the @function decorators, their docstrings, and the Doc() annotations on parameters, and produces CLI documentation from them, in any SDK language. The same metadata that describes a function in Python generates the same CLI interface as it would in Go or TypeScript.

You can drill into any function with --help:

dagger call build-backend --help
Build the FastAPI backend container.

Returns a Container — the core Dagger type representing an OCI image.
The caller can publish it, export it, or chain further operations on it.

USAGE
  dagger call build-backend [arguments]

ARGUMENTS
  --source Directory   Backend source directory [required]

Everything here (the description, the argument name, the type, the [required] marker) comes directly from your code. The Doc("Backend source directory") annotation you wrote in the Annotated[dagger.Directory, Doc("...")] parameter becomes the argument’s help text. The function’s docstring becomes the command’s description. There’s no separate documentation to write or maintain. The code is the documentation, and Dagger keeps it in sync for both the CLI and the SDK.

This is what makes self-service work. A developer who has never seen this module before can run dagger functions, pick the operation they need, check its arguments with --help, and run it, without reading source code or asking the platform team.

Running Functions

# Test the backend
dagger call test-backend --source=./backend

# Build the backend container
dagger call build-backend --source=./backend

# Test the frontend
dagger call test-frontend --source=./frontend

# Build the frontend (returns the dist/ directory)
dagger call build-frontend --source=./frontend

A few things to notice about the CLI:

  • dagger call is the universal entry point for running any function. It works the same way whether you’re calling a local module or a remote one (dagger call -m github.com/...).
  • Function names are converted from Python’s snake_case to CLI kebab-case automatically (build_backend → build-backend).
  • --source is not a built-in Dagger flag. It’s the parameter we defined with Annotated[dagger.Directory, Doc("...")]. Dagger turns every function parameter into a CLI flag, typed accordingly. A dagger.Directory parameter accepts a local path; a dagger.Secret would accept env:MY_SECRET or file:./key.json.
  • Return types determine what the CLI shows. test_backend returns a str, so Dagger prints the stdout. build_backend returns a Container, so Dagger shows the image digest. build_frontend returns a Directory, which you can export with dagger call build-frontend --source=./frontend export --path=./dist.

Same functions, same commands, for both apps. A developer debugging a build failure doesn’t push a commit and wait. They run the same command on their laptop, inspect the output, and fix the issue with the same tools they use for application code. CI/CD is part of the software factory.

From Laptop to CI: The Same Commands in GitHub Actions

We’ve been running everything locally. Now let’s prove the claim: the same code runs in CI without any changes. Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Test Backend
        uses: dagger/[email protected]
        with:
          version: "0.20.3"
          verb: call
          args: test-backend --source=./backend

      - name: Test Frontend
        uses: dagger/[email protected]
        with:
          version: "0.20.3"
          verb: call
          args: test-frontend --source=./frontend

That’s it. No Dockerfile to maintain. No shell scripts gluing steps together. No CI-specific logic. The dagger/dagger-for-github action installs the Dagger CLI and engine, and runs the exact same dagger call commands we just ran locally. The pipeline code doesn’t know, or care, where it’s running.

This is the minimal, working CI setup. Push it, open a pull request, and watch the same tests you just ran on your laptop execute in GitHub Actions. Same functions, same caching, same results.

Why Now

Three trends make this approach timely:

Platform Engineering is maturing. The core promise of Platform Engineering is developer self-service: give teams golden paths so they can ship without waiting for specialists. CI/CD modules are the missing piece in that story: reusable, versioned, self-documenting abstractions that reduce cognitive load the same way a Terraform module does. When CI/CD is software, it fits naturally into the Internal Developer Platform.

AI agents need programmatic interfaces. When your pipeline is real code with typed functions, AI agents can interact with it through tool use. YAML pipelines are opaque to agents. Typed Dagger functions are not. (We’ll explore this in Part 4.)

Multi-cloud is the norm. A pipeline that deploys to GCP today can deploy to AWS tomorrow with a module swap. No vendor lock-in at the CI layer. The platform team maintains the modules; developers maintain their code.

What’s Next

We now have a working CI pipeline, real code running both locally and in GitHub Actions with the same commands. But this minimal setup has limitations: every job starts with a cold cache, builds run on GitHub’s shared 2-vCPU runners, and there’s no persistent state between runs.

In Part 2, we’ll tackle the infrastructure side: caching strategies that cut build times by 5x, managed runners with persistent NVMe storage, and self-hosted Kubernetes runners with shared Dagger engines. Three approaches, each with different tradeoffs between simplicity and control.

The pipeline code won’t change. Only where it runs will.

Up Next: Part 2: Decoupling Pipelines from Infrastructure

This is Part 1 of a 4-part series. Follow for updates.

Tags: #cicd #dagger #platform-engineering #python #angular

Your AI Coding Agent Is Brilliant and Amnesiac. One File Fixes That.

I opened a new Claude Code session and asked it to add a feature to a tool I’d been building for three weeks. It asked me what language the project was in.

Three weeks. Dozens of sessions. Hundreds of decisions. And the AI started from zero — as if we’d never met.

That’s not a bug. That’s how every AI coding agent works. Claude Code, Cursor, Windsurf, Copilot — every session is a blank slate. The agent can write complex systems in minutes, but it can’t remember what you named the database table yesterday.

Most people solve this by re-explaining. They paste context into the first message. They describe the project. They remind the AI about conventions. Every single session.

Then they wonder why they’re burning through their Max plan 5x allowance in two hours.

Every re-explanation is tokens. Every clarifying question the AI asks because it doesn’t know your stack — tokens. Every time it suggests a migration you already rejected — tokens, plus the tokens you spend correcting it. You’re not paying for work. You’re paying for the AI to catch up to where it was yesterday.

There’s a file that fixes this. And most people either don’t have one, or they have one that makes things worse.

The Three Ways It Goes Wrong

After 60+ sessions building production tools, I’ve seen the same three mistakes — in my own projects and in every public repo I’ve checked.

1. The Empty File

No context file at all. Or something like:

# My Project
A web app built with Flask.

The AI knows nothing. It asks what framework you’re using — again. It suggests PostgreSQL when you already chose SQLite — again. Every clarifying question eats into the context window you need for actual work.

2. The Kitchen Sink

The opposite extreme: 400 lines. Full README. Changelog. API docs. Dependency lists. Three paragraphs of project philosophy. Installation instructions the AI will never use.

This costs money directly. That file loads into the context window at session start. On API pricing, 400 lines is roughly 8,000 tokens — loaded before you’ve typed a word. Across 50 sessions, that’s 400,000 tokens of instructions the AI re-reads every time. Most of it noise.

The AI doesn’t need your API documentation. It can read the code. It needs your operating context — the decisions, patterns, and gotchas that aren’t in the code.

3. The Stale Snapshot

A context file written two months ago that still references the database you migrated away from and the deployment target you abandoned.

This is the sneakiest failure mode. The AI generates code that looks correct but targets the wrong architecture. You don’t catch it until the tests fail — or worse, until production breaks.

Stale context is worse than no context. No context makes the AI ask questions. Stale context makes it confident and wrong.

What Actually Works

The fix isn’t “write a better prompt.” It’s context engineering — giving the AI the right operating context, compressed, current, and curated.

A working context file has seven sections:

  1. Project Summary — 2-3 lines. Stack, purpose, deployment target. Not what it could be. What it is right now.
  2. Architecture — One bullet per component. File name, what it does, what it connects to.
  3. Key Decisions — Things you’ve already decided. The AI should not relitigate these.
  4. Patterns & Conventions — How things work here. Not rules — descriptions of existing patterns.
  5. Gotchas & Pitfalls — Every gotcha entry is an hour of debugging you’ll never repeat.
  6. Current Progress — Where you are right now. Not where you were two months ago.
  7. Context — Hardware, environment, constraints the AI can’t infer from code.

Each section is compressed bullets with tags. The whole file stays under 100 lines. Everything loads fast, everything earns its place.

The difference between a project with this file and one without it is the difference between onboarding a new hire every morning and working with someone who was there yesterday.

The Playbook

I wrote the full system into a playbook — 8 chapters covering structure, cost discipline, security patterns, production gotchas, advanced features, and before/after transformations. Plus 6 copy-paste starter templates for different stacks (Python/Flask, Next.js, Rust, monorepo, Docker, solo dev).

It’s built from 60+ real sessions shipping real tools, not theory.

If you’re tired of re-explaining your project to your AI every session, this is the file that fixes it.

The CLAUDE.md Playbook — $12 on LemonSqueezy

Built by WebsationFlow. Zero-cost AI tools from consumer hardware.

5 AI Agent Memory Patterns That Actually Work (With Python Code)

5 AI Agent Memory Patterns That Actually Work (With Python Code)

Every AI agent starts stateless. Each request is a blank slate. That works for simple tasks, but the moment you need context — “what did the user ask yesterday?” or “what files did I already review?” — you need memory.

Here are 5 memory patterns, ordered from simplest to most powerful. Each one includes working code.

Pattern 1: Conversation Buffer (The Default)

Most frameworks give you this for free. Store the full conversation history and pass it back every time.

class BufferMemory:
    def __init__(self, max_messages: int = 50):
        self.messages: list[dict] = []
        self.max_messages = max_messages

    def add(self, role: str, content: str):
        self.messages.append({"role": role, "content": content})
        # Trim from the front when we hit the limit
        if len(self.messages) > self.max_messages:
            self.messages = self.messages[-self.max_messages:]

    def get_context(self) -> list[dict]:
        return self.messages.copy()

When to use: Simple chatbots, single-session tools.

The problem: Token cost scales linearly. At 50 messages, you’re burning thousands of tokens per call just on context. And when the session ends, everything disappears.

Pattern 2: Sliding Window + Summary

Keep recent messages verbatim, but compress older ones into a running summary.

import json

class SlidingWindowMemory:
    def __init__(self, window_size: int = 10):
        self.recent: list[dict] = []
        self.summary: str = ""
        self.window_size = window_size

    def add(self, role: str, content: str):
        self.recent.append({"role": role, "content": content})
        if len(self.recent) > self.window_size:
            overflow = self.recent[:-self.window_size]
            self.recent = self.recent[-self.window_size:]
            self._update_summary(overflow)

    def _update_summary(self, old_messages: list[dict]):
        # In production, use an LLM call here
        conversation = "n".join(
            f"{m['role']}: {m['content']}" for m in old_messages
        )
        # Simplified — replace with actual LLM summarization
        self.summary += f"n[Summarized: {len(old_messages)} messages about "
        self.summary += f"{old_messages[0]['content'][:50]}...]"

    def get_context(self) -> list[dict]:
        context = []
        if self.summary:
            context.append({
                "role": "system",
                "content": f"Previous conversation summary:n{self.summary}"
            })
        context.extend(self.recent)
        return context

When to use: Long conversations, customer support agents, coding assistants.

The key insight: Summaries are lossy. You trade precision for cost. The last 10 messages are exact; older ones are compressed. For most agents, this is the right tradeoff.

Pattern 3: File-Based Persistent Memory

The simplest form of persistence. Write memories to disk, load them next session.

import json
from pathlib import Path
from datetime import datetime

class FileMemory:
    def __init__(self, memory_dir: str = "./memory"):
        self.dir = Path(memory_dir)
        self.dir.mkdir(exist_ok=True)

    def save_daily(self, content: str):
        """Save a daily memory note."""
        today = datetime.now().strftime("%Y-%m-%d")
        path = self.dir / f"{today}.md"
        with open(path, "a") as f:
            timestamp = datetime.now().strftime("%H:%M")
            f.write(f"n## {timestamp}n{content}n")

    def save_entity(self, entity: str, facts: dict):
        """Save facts about a specific entity (user, project, etc)."""
        path = self.dir / "entities.json"
        entities = {}
        if path.exists():
            entities = json.loads(path.read_text())

        if entity not in entities:
            entities[entity] = {}
        entities[entity].update(facts)
        entities[entity]["_updated"] = datetime.now().isoformat()

        path.write_text(json.dumps(entities, indent=2))

    def load_recent(self, days: int = 3) -> str:
        """Load memory from the last N days."""
        memories = []
        for i in range(days):
            date = datetime.now() - timedelta(days=i)
            path = self.dir / f"{date.strftime('%Y-%m-%d')}.md"
            if path.exists():
                memories.append(path.read_text())
        return "n---n".join(memories)

    def search(self, keyword: str) -> list[str]:
        """Simple keyword search across all memory files."""
        results = []
        for path in self.dir.glob("*.md"):
            content = path.read_text()
            if keyword.lower() in content.lower():
                results.append(f"**{path.name}:**n{content}")
        return results

When to use: Personal assistants, project-specific agents, any agent that needs to “remember” across sessions.

Why this works: Files are debuggable. You can read them, edit them, version-control them. No database setup, no dependencies. This is how many production AI assistants actually work.

Pattern 4: Structured Memory with Decay

Not all memories are equal. Recent ones matter more. Frequently-accessed ones matter more. This pattern adds relevance scoring.

from dataclasses import dataclass, field
from datetime import datetime, timedelta
import math
import json

@dataclass
class Memory:
    content: str
    created: datetime = field(default_factory=datetime.now)
    last_accessed: datetime = field(default_factory=datetime.now)
    access_count: int = 0
    importance: float = 0.5  # 0-1 scale

    def relevance_score(self) -> float:
        """Combine recency, frequency, and importance."""
        hours_since_access = (
            datetime.now() - self.last_accessed
        ).total_seconds() / 3600

        # Exponential decay: half-life of 24 hours
        recency = math.exp(-0.693 * hours_since_access / 24)

        # Log-scale frequency boost
        frequency = math.log(self.access_count + 1) / 5

        # Weighted combination
        return (0.4 * recency) + (0.2 * frequency) + (0.4 * self.importance)


class DecayMemory:
    def __init__(self, max_memories: int = 1000):
        self.memories: list[Memory] = []
        self.max_memories = max_memories

    def add(self, content: str, importance: float = 0.5):
        mem = Memory(content=content, importance=importance)
        self.memories.append(mem)
        self._prune()

    def recall(self, top_k: int = 10) -> list[str]:
        """Return the most relevant memories."""
        scored = sorted(
            self.memories,
            key=lambda m: m.relevance_score(),
            reverse=True
        )[:top_k]

        # Mark as accessed
        for m in scored:
            m.last_accessed = datetime.now()
            m.access_count += 1

        return [m.content for m in scored]

    def _prune(self):
        """Remove lowest-relevance memories when over capacity."""
        if len(self.memories) > self.max_memories:
            self.memories.sort(key=lambda m: m.relevance_score())
            self.memories = self.memories[-self.max_memories:]

When to use: Long-running agents that accumulate thousands of memories. Agents that need to “forget” gracefully.

The math: The relevance score combines three signals — recency (exponential decay with 24h half-life), access frequency (log-scaled), and importance (set when memory is created). Tweak the weights for your use case.

Pattern 5: Vector-Backed Semantic Memory

When keyword search isn’t enough. Store memories as embeddings, retrieve by meaning.

import numpy as np
from openai import OpenAI

class VectorMemory:
    def __init__(self):
        self.client = OpenAI()
        self.memories: list[dict] = []  # {text, embedding, metadata}

    def _embed(self, text: str) -> list[float]:
        response = self.client.embeddings.create(
            model="text-embedding-3-small",
            input=text
        )
        return response.data[0].embedding

    def add(self, text: str, metadata: dict = None):
        embedding = self._embed(text)
        self.memories.append({
            "text": text,
            "embedding": embedding,
            "metadata": metadata or {},
            "created": datetime.now().isoformat(),
        })

    def search(self, query: str, top_k: int = 5) -> list[str]:
        """Find memories most similar to the query."""
        query_embedding = self._embed(query)

        scored = []
        for mem in self.memories:
            similarity = self._cosine_similarity(
                query_embedding, mem["embedding"]
            )
            scored.append((similarity, mem["text"]))

        scored.sort(reverse=True)
        return [text for _, text in scored[:top_k]]

    @staticmethod
    def _cosine_similarity(a: list[float], b: list[float]) -> float:
        a, b = np.array(a), np.array(b)
        return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

When to use: Knowledge bases, RAG systems, agents that need to find relevant info across thousands of unstructured memories.

Production tip: For anything beyond 10K memories, use a vector database (Chroma, Pinecone, pgvector) instead of in-memory cosine similarity. The pattern stays the same — only the storage layer changes.

Choosing the Right Pattern

Pattern Persistence Token Cost Complexity Best For
Buffer None High (linear) Trivial Quick prototypes
Sliding Window None Medium (capped) Low Long conversations
File-Based Disk Low (load on demand) Low Personal assistants
Structured Decay Disk/DB Low (top-K only) Medium Long-running agents
Vector-Backed DB Low (top-K only) Higher Knowledge-heavy agents

Start with Pattern 3 (file-based). It’s simple, debuggable, and handles most use cases. Add vector search only when keyword matching fails.

The Hybrid Approach

Production agents typically combine patterns:

class HybridMemory:
    def __init__(self):
        self.buffer = SlidingWindowMemory(window_size=10)
        self.files = FileMemory("./memory")
        self.vectors = VectorMemory()

    def get_context(self, query: str = None) -> list[dict]:
        context = []

        # Layer 1: Recent conversation (always included)
        context.extend(self.buffer.get_context())

        # Layer 2: Today's notes (file-based)
        daily = self.files.load_recent(days=1)
        if daily:
            context.append({
                "role": "system",
                "content": f"Today's notes:n{daily}"
            })

        # Layer 3: Semantic recall (if we have a query)
        if query:
            relevant = self.vectors.search(query, top_k=3)
            if relevant:
                context.append({
                    "role": "system",
                    "content": f"Relevant memories:n" + 
                              "n".join(f"- {m}" for m in relevant)
                })

        return context

Short-term buffer for the current conversation. Files for structured daily/entity data. Vectors for semantic search across everything.

Key Takeaways

  1. Stateless is a bug, not a feature — agents without memory repeat mistakes and lose context
  2. Files beat databases for most agent memory needs — simpler, more debuggable
  3. Decay matters — not all memories deserve equal weight
  4. Hybrid wins — combine patterns for different time horizons
  5. Start simple — you can always add vector search later

This is part of the “AI Engineering in Practice” series — practical guides for developers building with AI. Follow for more.

The History of Disposable Email

The first disposable email service launched in 2000. A developer named Josh built Spamgourmet — you created addresses like [email protected], where the 3 meant “accept three emails then stop forwarding.” The counter was baked into the address itself. No web UI needed. Josh maintained it until he died of brain cancer in 2020. His son Josiah runs it now.

Twenty-six years later, AI agents are creating their own email inboxes without human intervention. The throughline between these two things is shorter than you’d think.

Era 1: Anti-Spam (2000-2010)

By the late 1990s, every website wanted your email to register. Every registration meant more spam. The CAN-SPAM Act wouldn’t arrive until 2003. People needed a way to give out email addresses that worked but didn’t matter.

Mailinator (2003) defined the category. Paul Tyma, a Google engineer, got the idea from his drunk roommate. The first version took three days to code. The concept: any email sent to [email protected] creates an inbox. No signup. No password. Just visit the site, type the address, and read whatever arrived.

The original ran on a single server — AMD 2GHz Athlon, 1GB RAM, 80GB IDE hard drive. It processed 4.5 million emails a day. Tyma’s architecture blog post went viral on Hacker News: keep everything in RAM, delete oldest first, never touch disk. The entire stack — web app, SMTP server, storage — ran in a single JVM. The Washington Post and New York Times wrote about it.

10 Minute Mail (2006) added the countdown timer. Devon Hillard built it as a weekend project to learn JBoss Seam. The UX innovation was the constraint — your address self-destructs in 10 minutes. No choices to make. Visit the site, get an address, use it, walk away. It spawned an entire genre: 20 Minute Mail, 30 Minute Mail, Minute Inbox.

Guerrilla Mail (2006) came out of Chicago and quietly became a machine. Twenty billion emails processed over its lifetime. A hundred and thirty thousand emails per hour, currently. The team built their own SMTP server in Go — open-sourced as Go-Guerrilla — because existing mail servers were too bloated. Guerrilla Mail was also the first to let you send from a disposable address, not just receive.

YOPmail (2004) won France. TrashMail (2002) won Germany. Discardmail (2004) survived two decades under four different names. Each served the same purpose: a real email address for a task you didn’t care about keeping.

The users were humans. The interface was a browser. The business model was free. The enemy was spam.

The Blocklist Wars

As disposable email services multiplied, websites fought back.

Websites built blocklists of known disposable domains. Services created alternate domains — Mailinator alone has hundreds, including gems like thisisnotmyrealemail.com. Open-source blocklists appeared on GitHub around 2014, community-maintained and updated daily. By 2025, they tracked over 110,000 disposable email domains.

The cat-and-mouse game escalated. Services rotated domains. Users pointed their own custom MX records at Mailinator — completely undetectable by blocklists. Detection APIs emerged — IPQualityScore, UserCheck — offering real-time disposable email identification as a service.

Temp-mail.org won this war through distribution, not technology. The domain was nearly perfect for search intent. Available in 29 languages. Chrome extension. Android app. 56% of their 15 million monthly visits come from organic search. They didn’t build the best disposable email service. They built the most findable one.

Era 2: Software Testing (2011-2024)

Around 2011, the use case shifted. Disposable email stopped being about avoiding spam and started being about testing software.

Guerrilla Mail’s API (April 2011) was the first. Free, public, no registration — developers could create and check disposable inboxes programmatically. This was the moment disposable email became infrastructure instead of a consumer tool.

Mailsac (2012) and Mailosaur (2013) were built specifically for QA teams. The founders needed to test their own email workflows, built their own tools, and opened them up. These integrated with Selenium, Playwright, Cypress — the browser automation frameworks that QA teams were already using.

Mailinator pivoted. What started as a free consumer tool became an enterprise email testing platform. Private domains, REST API, webhooks, SMS testing (added in 2018), email load testing. Pricing went from free to $79-$459 per month. Revenue hit $126K with 300 enterprise customers by 2017, bootstrapped.

The customer was no longer a person avoiding spam. It was a QA team running email tests in CI/CD pipelines. The interface was an API, not a browser. The business model was SaaS.

Meanwhile, Big Tech legitimized email aliasing for consumers. Apple launched Hide My Email in 2021 — unlimited random addresses forwarding to your real inbox, $0.99/month with iCloud+. Mozilla launched Firefox Relay. Proton acquired SimpleLogin in 2022. The concept Spamgourmet pioneered in 2000 became a first-party feature in every major ecosystem.

By 2024, the disposable email market was valued at $1.36 billion, growing at 11% annually, projected to hit $3.53 billion by 2033.

Era 3: AI Agents (2025-present)

The user changed again. It’s no longer a human avoiding spam or a QA team testing workflows. It’s an autonomous AI agent that needs an email address to complete a task.

The trigger was OpenClaw launching in late January 2026. Suddenly, hundreds of thousands of persistent AI agents were running on VPSes, browsing the web, signing up for services, and hitting the same wall: registration forms that require email verification.

AgentMail raised $6 million in March 2026 to build persistent email identities for agents. Founded by an ex-Optiver quant, an ex-NVIDIA autonomous vehicles engineer, and an ex-Accel investor. General Catalyst led the round. Paul Graham and Dharmesh Shah (HubSpot CTO) are angels. Their user count tripled when OpenClaw launched, quadrupled in February.

The defining moment from their blog: autonomous agents started signing up for AgentMail on their own — finding it through web search, navigating to the site, and creating inboxes without a developer in the loop. AgentMail built agent.email — a landing page designed not for humans but for agents to self-provision.

KeyID took a different approach — free email, phone, and SMS infrastructure for agents. Ed25519 keypair auth instead of API keys. MCP server with 47 tools. Free at 1,000 accounts.

Agent Burner is essentially temp-mail.org for agents — no account, no API key, just HTTP. A single POST creates a disposable inbox that auto-expires in an hour. URLs are pre-extracted from incoming emails so the agent doesn’t parse HTML. The same need Mailinator solved for humans in 2003, but now the user types curl instead of visiting a website.

The throughline

Every era of disposable email solves the same problem for a different user.

2003: A human needs to register for a website without getting spammed. They visit Mailinator in a browser and type an address.

2013: A QA team needs to test a signup flow in CI. They call an API to create an inbox, run the test, and check for the verification email programmatically.

2026: An autonomous agent needs to sign up for a service at 3am without human intervention. It creates an inbox via HTTP, fills a registration form in Turkish, receives a verification email, extracts the link, and deletes the inbox. The address existed for four minutes.

The throughline: something needs a real email address for a task, but doesn’t want to keep it. The technology barely changed. A disposable email service in 2026 does the same thing Mailinator did in 2003 — receive an email and let someone read it. What changed is who’s reading it, and why.

Paul Tyma built Mailinator because his drunk roommate had an idea. Josh built Spamgourmet and maintained it until he couldn’t anymore. Devon Hillard built 10 Minute Mail to learn a framework. The Go-Guerrilla team built their own SMTP server because Sendmail was too bloated. Guerrilla Mail has processed twenty billion emails without venture funding.

Disposable email is a category that keeps getting reinvented because the problem never goes away. Someone always needs an address they don’t plan to keep.

Big Tech firms are accelerating AI investments and integration, while regulators and companies focus on safety and responsible adoption.

The AI landscape is experiencing unprecedented growth and transformation. This post delves into the key developments shaping the future of artificial intelligence, from massive industry investments to critical safety considerations and integration into core development processes.

Key Areas Explored:

  • Record-Breaking Investments: Major tech firms are committing billions to AI infrastructure, signaling a significant acceleration in the field.
  • AI in Software Development: We examine how companies are leveraging AI for code generation and the implications for engineering workflows.
  • Safety and Responsibility: The increasing focus on ethical AI development and protecting vulnerable users, particularly minors.
  • Market Dynamics: How AI is influencing stock performance, cloud computing strategies, and global market trends.
  • Global AI Strategies: Companies are adapting AI development for specific regional markets.

This deep dive aims to provide developers, tech leaders, and enthusiasts with a comprehensive overview of the current state and future trajectory of AI.

AI #ArtificialIntelligence #TechTrends #SoftwareEngineering #MachineLearning #CloudComputing #FutureOfTech #AISafety

Securing AI Access to Financial Data: How We Govern Bitcoin MCP with agentgateway

Securing AI Access to Financial Data: How We Govern Bitcoin MCP with agentgateway

What happens when an AI agent has a tool called send_raw_transaction?

That’s not a hypothetical. bitcoin-mcp is a Model Context Protocol server that gives AI agents access to 49 tools for querying the Bitcoin network — block analysis, fee estimation, mempool inspection, address lookups, transaction decoding, and yes, broadcasting signed transactions to the network.

For a data analyst building a dashboard, these tools are a goldmine. For a production deployment without guardrails, they’re an open door.

We built Bitcoin Gateway Guard to close that door and hand out keys only to the people who should have them.

The Threat Model

AI agents are not users. They don’t have judgment. They do exactly what their prompt says, and prompts can be injected, manipulated, or just poorly written. Consider what goes wrong when an AI agent has unrestricted access to Bitcoin tools:

Accidental broadcast. A developer testing transaction construction accidentally calls send_raw_transaction instead of decode_raw_transaction. The transaction is irreversible.

Prompt injection. A malicious input tricks the agent into calling generate_keypair, and the private key appears in a response that gets logged, cached, or displayed in a UI.

Runaway loops. A bug in the agent’s reasoning causes it to hammer the Bitcoin node with thousands of requests per minute, degrading service for everyone.

Shadow access. Six months later, nobody can tell you which agents accessed what data, when, or why. Your compliance team is not happy.

These are not exotic attacks. They are the predictable consequences of deploying AI agents on financial infrastructure without the same controls you’d apply to human users.

Four Layers of Defense

Bitcoin Gateway Guard places agentgateway — an open-source MCP security gateway built in Rust — between AI agents and bitcoin-mcp. Every request passes through four independent security layers.

Layer 1: JWT Authentication

No anonymous access. Every request must carry a valid JWT signed with RS256. The gateway validates the signature, issuer (bitcoin-gateway-guard), audience (bitcoin-mcp), and expiration before the request touches any downstream logic.

No token? HTTP 401. Expired token? HTTP 401. Wrong issuer? HTTP 401.

Layer 2: Role-Based Access Control

We define two roles using CEL (Common Expression Language) expressions:

jwt.role == "admin"
jwt.role == "reader" && mcp.tool.name != "send_raw_transaction" && mcp.tool.name != "generate_keypair"

The reader role gets access to 47 read-only tools — everything you need for dashboards, analytics, research, and monitoring. The two tools that can modify state or create sensitive material — send_raw_transaction and generate_keypair — are reserved for admin.

This is a single line of policy. No code. No middleware. No if-else chains buried in application logic. The policy is auditable, declarative, and lives in a YAML config file that can be version-controlled and reviewed.

Layer 3: Rate Limiting

A token bucket algorithm caps every consumer at 10 requests per minute with burst capacity of 10. This is deliberately conservative. A legitimate dashboard refresh needs one request every few seconds. An agent stuck in a loop will hit the limit in under a second.

localRateLimit:
  - maxTokens: 10
    tokensPerFill: 1
    fillInterval: 60s

Rate limiting is the safety net that catches everything else. Even if authentication and authorization both pass, a runaway agent cannot overload your Bitcoin node.

Layer 4: Audit Trail

Every tool invocation generates an OpenTelemetry span exported to Jaeger. Each span records the tool name, the JWT subject (who), the JWT role, the authorization decision (allowed or denied), the HTTP status, and the latency.

Open Jaeger at localhost:16686, select the agentgateway service, and you have a complete, searchable, time-ordered record of every AI agent interaction with your Bitcoin data. Feed it into your SIEM, set up PagerDuty alerts on denied requests, run weekly access reviews.

The Demo

Setup takes two commands:

python scripts/generate_keys.py   # Generate RSA keys + JWT tokens
docker compose up -d               # Start bitcoin-mcp + agentgateway + Jaeger

Then test the security model:

# Reader queries block height -- works fine
python scripts/test_rbac.py --user reader --tool get_block_count
# Result: ALLOWED

# Reader tries to broadcast a transaction -- blocked
python scripts/test_rbac.py --user reader --tool send_raw_transaction
# Result: DENIED (authorization)

# Burst 15 requests -- rate limited after 10
python scripts/test_rbac.py --user reader --tool get_fee_estimates --burst 15
# Summary: 10 allowed, 5 rate-limited

Every one of these interactions shows up in Jaeger with full context. The denied requests are flagged. The rate-limited requests are logged. Nothing is invisible.

Why agentgateway?

We evaluated building custom middleware, wrapping bitcoin-mcp with authentication logic, and using a general-purpose API gateway. agentgateway won because it understands MCP natively.

A generic API gateway can rate-limit HTTP requests and validate JWTs. But it cannot inspect MCP tool names, apply per-tool authorization rules, or trace MCP-specific context. agentgateway speaks the protocol. Its CEL-based authorization rules reference mcp.tool.name directly. No regex on request bodies. No custom plugins. No fragile parsing.

The configuration is 40 lines of YAML. The entire security policy — authentication, authorization, rate limiting, and tracing — is declared in a single file that a security engineer can review in five minutes.

Why This Matters

The AI industry is moving fast toward giving agents access to real-world systems. Financial data. Infrastructure controls. Business operations. The tools are getting more powerful. The guardrails are not keeping up.

MCP is becoming the standard protocol for AI agent tool access. But MCP itself has no built-in security model. There’s no authentication, no authorization, no rate limiting, and no audit trail in the protocol specification. That’s by design — MCP is a transport protocol, not a security framework.

The security has to come from the deployment layer. And it has to be as easy to configure as the tools themselves, or teams will skip it.

Bitcoin Gateway Guard demonstrates that you can add enterprise-grade security to any MCP server — not just bitcoin-mcp — with a single configuration file and zero code changes to the underlying server. The same pattern applies to database MCP servers, cloud infrastructure tools, payment APIs, or anything else you’d hesitate to give an AI agent unrestricted access to.

The question is not whether AI agents should have access to financial tools. They should — the productivity gains are real. The question is whether that access is governed with the same rigor we apply to human users.

With agentgateway, the answer is yes. And you can prove it to your auditors.

Bitcoin Gateway Guard is open source. The full configuration, Docker setup, and test scripts are available at github.com/Bortlesboat/bitcoin-gateway-guard. Built with bitcoin-mcp and agentgateway for the MCP & AI Agents Hackathon — Secure & Govern MCP category.

I Built My First AI Agent That Understands Bitcoin — Here’s How

I Built My First AI Agent That Understands Bitcoin — Here’s How

A beginner’s guide to MCP, agentgateway, and bitcoin-mcp

I wanted to build an AI agent that could answer questions about Bitcoin. Not from a static FAQ — I mean real-time data. Current block height. Mempool congestion. Fee estimates. Mining stats.

I figured this would take days of API wrangling, authentication headaches, and custom plumbing. It took 10 minutes.

Here’s what I learned.

The Problem

AI models are smart, but they’re stuck in the past. Ask Claude or GPT about the current Bitcoin block height and you’ll get a polite “I don’t have access to real-time data.” Fair enough — but what if you could give them that access?

That’s what MCP does.

What is MCP?

Model Context Protocol (MCP) is an open standard that lets AI models call external tools. Think of it like USB for AI: a universal plug that connects any AI client to any data source. An MCP server exposes tools (like “get the current Bitcoin block height”). An MCP client (like Claude Desktop or a custom script) calls those tools. The AI model decides which tools to call based on your question.

The beauty is separation of concerns. The Bitcoin logic lives in the MCP server. The AI logic lives in the client. They speak the same protocol.

What is agentgateway?

agentgateway is a lightweight proxy that sits between your AI client and your MCP servers. Why do you need a proxy? A few reasons:

  • Protocol translation — it can bridge different MCP transports (stdio, SSE, Streamable HTTP)
  • Routing — it can fan out to multiple MCP servers from a single endpoint
  • Security — it’s where you’d add authentication, rate limiting, and audit logging in production

For our purposes, it gives us a clean HTTP endpoint that any MCP client can connect to.

The Stack

Here’s what we’re building:

AI Client  --->  agentgateway (port 3000)  --->  bitcoin-mcp (port 8000)

Three components. Two commands to start. One config file.

Let’s Build It

Step 1: Install bitcoin-mcp

pip install bitcoin-mcp

That’s the entire Bitcoin integration. bitcoin-mcp ships with 49 tools for querying the Bitcoin network, and it works out of the box — no API keys, no local node required. It connects to the free Satoshi API by default.

Step 2: Start bitcoin-mcp

bitcoin-mcp --transport sse --port 8000

This starts an MCP server on port 8000 using Server-Sent Events transport. Leave this terminal running.

Step 3: Get agentgateway

Download the binary from the agentgateway releases page for your platform.

Step 4: Create the Config

Save this as config-sse.yaml:

binds:
- port: 3000
  listeners:
  - routes:
    - policies:
        cors:
          allowOrigins: ["*"]
          allowHeaders: ["*"]
          exposeHeaders: ["Mcp-Session-Id"]
      backends:
      - mcp:
          targets:
          - name: bitcoin-mcp
            sse:
              url: http://localhost:8000/sse

This tells agentgateway to listen on port 3000 and forward everything to bitcoin-mcp.

Step 5: Start agentgateway

./agentgateway -f config-sse.yaml

Step 6: Connect

Open MCP Inspector:

npx @modelcontextprotocol/inspector

Set the transport to Streamable HTTP, enter http://localhost:3000, and click Connect.

Go to the Tools tab. You’ll see 49 tools. Click get_situation_summary and hit Call.

The “Aha” Moment

When I called get_situation_summary for the first time, I got back a wall of structured data: block height, hashrate, difficulty adjustment progress, mempool size, fee tiers, mining pool distribution. Real data. Live from the Bitcoin network.

I didn’t write any Bitcoin code. I didn’t parse any APIs. I didn’t handle any authentication. I installed an MCP server, pointed a proxy at it, and connected.

That’s when MCP clicked for me. The AI doesn’t need to know how Bitcoin works. It just needs to know which tool to call. The MCP server handles everything else.

I tried more tools. get_fee_estimates told me exactly what fee to use for a transaction right now. analyze_mempool showed me the current congestion. get_mining_info gave me hashrate and difficulty data. Each one returned structured, accurate, real-time data.

Then I connected it to Claude Desktop (just a one-line config change) and asked in natural language: “What’s the current state of the Bitcoin mempool?” Claude called the right tools automatically and gave me a clear, conversational summary backed by live data.

What I Learned

MCP is simpler than I expected. I kept looking for the catch — the complex authentication step, the mandatory configuration file with 200 options, the thing that would take an hour to debug. It wasn’t there. The protocol is genuinely simple.

agentgateway solves real problems. Without it, you’d need every AI client to support every MCP transport. With it, you expose one HTTP endpoint and route to any backend. It’s the missing piece between “I have an MCP server” and “any client can use it.”

The tool ecosystem is the killer feature. bitcoin-mcp alone has 49 tools. Imagine stacking multiple MCP servers — weather, databases, APIs, Bitcoin — all accessible through one gateway. That’s where this gets powerful.

Separation of concerns matters. The AI model picks which tools to call. The MCP server implements the tools. The gateway handles routing and security. Each piece does one thing well.

What’s Next

This starter example runs everything locally with no authentication. For production, you’d want:

  • Authentication — agentgateway supports JWT validation and API keys
  • Rate limiting — prevent abuse
  • Audit logging — track what tools are being called and by whom

I’m also looking into publishing bitcoin-mcp to an MCP registry so anyone can discover and use it without knowing the GitHub URL.

Try It Yourself

The full code is in the hackathon repo. Clone it, follow the README, and you’ll have a working Bitcoin AI agent in 10 minutes.

If you’ve been curious about MCP but haven’t tried it yet — this is your on-ramp. The barrier is genuinely low, and the payoff is seeing an AI agent answer questions with real-time data for the first time.

Built with bitcoin-mcp, agentgateway, and an unreasonable enthusiasm for Bitcoin tooling.

11. Bootstrap

BootCamp by Dr.Angela

1. What is Bootstrap?

  • CSS Frameworks: Bootstrap, Foundation, MUI, Tailwind, etc.
  • When to use vs not to use
    • Use: for fast, responsive, mobile-first development
    • Avoid: for very simple projects or highly customized/complex designs
  • How to use Bootstrap (CDN; Content Delivery Network)
    • Official website: https://getbootstrap.com/
    • CSS (inside ) : <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
    • min → minified file (faster loading, no unnecessary spaces)
    • JavaScript (before ) : <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>

2. Bootstrap Layout

  • 12-column system : Uses container → row → col(items)
  • Auto fit : Columns automatically adjust based on available space
  • Containers : .container, .container-sm(mobile)/md(iPad)/lg(laptop)/xl(desktop)/xxl(TV)/fluid(full-width)
  • Sized columns
    • ex.
      <div class="container">
      <div class="row">
      <div class="col-2">Hello</div>
      <div class="col-4">Hello</div>
      <div class="col-6">Hello</div>
      </div>
      </div>
  • Breakpoints : <576px, ≥576px, ≥768px, ≥992px, ≥1200px, ≥1400px
    • Multiple breakpoints
    • ex. <div class="col-sm-12 col-md-8 col-lg-4"></div>

3. Bootstrap Components

  • Common components: card, button, carousel, navbar, etc.
  • Navbar : Can include icons using SVG
    • Download and use : ex. <img src="./briefcase.svg" alt="briefcase" height="30">
    • Or copy SVG directly: ex. <svg ~ > ~ </svg>
  • Snippets (Pre-built sections) : Heroes, Features, Footers, …
    • How to use : Inspect → copy → paste → customize
  • Carousel : Use inside a .container for responsive layout
    • ex.
      <div class="container">
      <!-- carousel here -->
      </div>
  • Spacing(ex. mb-2)
  • Structure
    • Property: m (margin), p (padding)
    • Side: t (top), b (bottom), s (start), e (end), x, y
    • Size: 0 / 1 / 2 / 3 / 4 / 5 / auto
  • Dark Mode : ex. <html lang="en" data-bs-theme="dark">
  • Jumbotron-style : Used for highlighting user feedback(Testimonials) or important content

From PyPI to Production: Publishing bitcoin-mcp to agentregistry

From PyPI to Production: Publishing bitcoin-mcp to agentregistry

How we turned a 49-tool Bitcoin MCP server into a one-command deployment for any AI agent.

The MCP Server Discovery Problem

The Model Context Protocol ecosystem is exploding. There are over 100 MCP servers on PyPI and npm right now, covering everything from GitHub to Slack to databases. But here is the uncomfortable truth: finding and deploying them is still painful.

The typical workflow looks like this:

  1. Google “bitcoin mcp server”
  2. Find a GitHub repo (maybe)
  3. Read the README to figure out installation
  4. Determine the transport type (stdio? SSE? streamable HTTP?)
  5. Manually edit your IDE’s MCP configuration file
  6. Restart your IDE and pray it connects

This is 2026. We send agents to browse the web and write code. We should not need 10 minutes of manual config to add a tool.

Enter agentregistry

agentregistry solves this by creating a centralized, searchable registry for MCP servers, skills, and agent blueprints. Think of it as npm for AI agents — you search, you install, you go.

We decided to register bitcoin-mcp as the first Bitcoin-focused entry. Here is how that went.

The Registration Process

What We Had

bitcoin-mcp is a production MCP server for Bitcoin network analysis:

  • 49 tools for mempool analysis, fee estimation, block inspection, transaction decoding, mining insights, and market data
  • 116 tests with full CI coverage
  • Zero configuration — it auto-detects a local Bitcoin Core node or falls back to the free Satoshi API
  • Already published on PyPI as bitcoin-mcp

The server was ready. What it lacked was discoverability.

What We Built

Three pieces make up the agentregistry submission:

1. Server Registration

The straightforward part. We registered bitcoin-mcp as a PyPI-sourced server with stdio transport:

arctl register server 
    --name bitcoin-mcp 
    --source pypi 
    --package bitcoin-mcp 
    --transport stdio 
    --command bitcoin-mcp

Now arctl search bitcoin finds it. arctl install bitcoin-mcp installs it. Done.

2. The Bitcoin Skill

This is where it gets interesting. MCP servers give agents tools. But tools without context are like giving someone a stethoscope without medical school. They can press buttons, but they do not know what the results mean.

We created a Bitcoin Intelligence Skill — a structured knowledge document (SKILL.md) that teaches the agent:

  • When to use get_fee_estimates vs estimate_smart_fee vs get_fee_recommendation
  • How to interpret mempool congestion (50 MB = congested, 100 MB = severely congested)
  • Confirmation thresholds based on transaction value ($1K = 1 conf, $100K = 6+ conf)
  • How to read mining pool rankings and difficulty adjustments
  • Correlation patterns between on-chain data and market movements

The skill turns a generic tool-caller into a Bitcoin analyst.

3. The Agent Blueprint

The agent.yaml ties everything together into a single deployable unit:

name: bitcoin-intelligence
skills:
  - bitcoin-skill
servers:
  - name: bitcoin-mcp
    source: pypi
    package: bitcoin-mcp

One command deploys the complete package: arctl deploy bitcoin-intelligence.

The Demo

Here is what one-command deployment looks like in practice:

$ arctl deploy bitcoin-intelligence
Installing bitcoin-mcp from PyPI... done
Loading bitcoin-skill... done
Configuring IDE... done

Bitcoin Intelligence agent is ready.

Then, in your AI assistant:

You: “Should I send this Bitcoin transaction now or wait?”

Agent: checks mempool (42MB, moderate congestion), gets fee estimates (12 sat/vB for next block), looks at recent block intervals (9.2 min average — slightly fast)

“The mempool is moderately congested at 42MB. Current next-block fee is 12 sat/vB (~$2.40 for a typical transaction). Blocks are coming slightly faster than average, so the mempool should clear over the next hour. If you can wait 30-60 minutes, you will likely see fees drop to 6-8 sat/vB. If it is urgent, 12 sat/vB will get you into the next block.”

That response required three different tools, domain knowledge about fee interpretation, and an understanding of how block timing affects mempool dynamics. The skill makes this possible.

What We Learned

Skills are the missing layer. Everyone talks about MCP tools, but domain knowledge is what separates a useful agent from a button-masher. The skill concept in agentregistry is underexplored and powerful.

Zero-config matters for registries. bitcoin-mcp works without any API keys or node setup because it falls back to a hosted API. This is critical for registry deployments — if arctl install requires a 10-step configuration guide, you have lost the whole point.

Agent blueprints enable composition. The agent.yaml format lets you bundle servers and skills into opinionated packages. Imagine a “DeFi analyst” blueprint that combines bitcoin-mcp, an Ethereum MCP server, and a cross-chain skill. Registries make this composable.

Try It Yourself

pip install arctl
arctl search bitcoin
arctl deploy bitcoin-intelligence

Three commands. Zero to Bitcoin intelligence.

The full source is at github.com/Bortlesboat/hackathon-mcp.

Built for the MCP Hackathon — Explore Agent Registry category.
bitcoin-mcp: PyPI | GitHub
agentregistry: agentregistry.dev