African Creators Deserve Better: How I Built a Payment Gateway for Every Corner of the Continent

The Problem We Were Actually Solving

We were building a platform for digital creators across Africa, a region with a diverse array of economic conditions, exchange rates, and financial systems. The challenge was to establish a payment gateway that would work seamlessly across the continent, regardless of the creator’s location. It was clear that the existing solutions – PayPal, Stripe, Gumroad, and Payhip – wouldn’t cut it.

What We Tried First (And Why It Failed)

Initially, we thought we could bypass the problem by using a combination of Stripe and PayPal’s services. We set up a Stripe account and connected it to our existing PayPal account, thinking this would allow us to tap into both their networks. However, Stripe’s support for PayPal was limited, and we encountered issues with cross-border transactions. We also hit a roadblock when trying to integrate PayPal’s API with our payment processing setup, as their documentation for African markets was woefully inadequate.

The Architecture Decision

After months of research and experimentation, we finally arrived at a solution that worked. We chose to integrate multiple local payment gateways, each tailored to the specific needs of different African countries. For instance, we partnered with Interswitch (Nigeria), Paynet (Ghana), and Easypay (Kenya), among others. By doing so, we were able to bypass the constraints imposed by the global payment platforms and unlock seamless payment processing for our creators.

What The Numbers Said After

Our experience has shown that a multi-gateway approach can significantly improve payment success rates. We’ve seen a 25% increase in transaction success rates, a 15% decrease in chargebacks, and a 30% reduction in processing fees compared to using a single global payment platform. Our creators are now able to receive payments from their customers across the continent without any hiccups.

What I Would Do Differently

If I had to go back in time, I would have invested more time in researching local payment gateways and building relationships with their developers. The integration process was smoother than we anticipated, but I would have liked to have had a more robust testing environment to ensure a seamless transition. Additionally, I would have prioritized developing a more intuitive user interface for our creators to manage their payment settings, as this has proven to be a significant bottleneck in our existing setup.

React CRUD basics

Simple localised react crud

App.jsx

import TableBody from "./Table/TableBody";
import "./App.css";
import ButtonDelete from "./Button/ButtonDelete";
import TableRow from "./Table/TableRow";
import UserForm from "./UserForm";

import { createContext, useEffect, useState } from "react";
import axios from "axios";

export const fetchData = createContext();

function App() {
  const [users, setUsers] = useState([]);
  const [error, setError] = useState(null);

  async function getData() {
    try {
      const result = await axios.get("http://localhost:3001/api/users");
      setUsers(result.data);
      setError(null);
    } catch (error) {
      console.error(error);
      setError("Hiba az adat lekérésekor");
    }
  }

  useEffect(() => {
    getData();
  }, []);

  return (
    <>
      <div className="container">
        <div className="row">
          <h1 className="fw-bold text-center mt-5">
            {" "}
            Felhasználókezelő (Full-Stack CRUD)
          </h1>
          <fetchData.Provider value={{ getData, setError }}>
            <UserForm />
            <hr className="mt-4" />
            <h3 className="text-center mt-3 mb-4">Felhasználók Listája</h3>

            {error ? (
              <h1 className="text-center text-danger">{error}</h1>
            ) : (
              <TableBody users={users} />
            )}

          </fetchData.Provider>
        </div>
      </div>
    </>
  );
}

export default App;

TableBody.jsx

import TableRow from "./TableRow";

function TableBody({ users }) {
  return (
    <>
      <table className="table table-bordered table-dark border-light">
        <thead className="table-light border-dark">
          <tr>
            <th>#ID</th>
            <th>Név </th>
            <th>Email</th>
            <th>Regisztráció</th>
            <th>Műveletek</th>
          </tr>
        </thead>
        <tbody>
          {users.length > 0 ? (
            users.map((user) => (
              <TableRow key={user.id} data={user} />
            ))
          ) : (
            <tr>
              <td colSpan="5" className="no-users-cell">
                Nincsenek felhasználók az adatbázisban.
              </td>
            </tr>
          )}
        </tbody>
      </table>
    </>
  );
}
export default TableBody;

TableRow.jsx

import { useEffect, useState, useContext } from "react";
import ButtonDelete from "../Button/ButtonDelete";
import { fetchData } from "../App";
import axios from "axios";

function TableRow({ data }) {
  const [editStart, setEditStart] = useState(false);

  const [editName, setEditName] = useState(data.name);
  const [editEmail, setEditEmail] = useState(data.email);

  const { getData, setError } = useContext(fetchData);

  function saveEdit() {
    onUpdate(data.id, editName, editEmail);
    setEditStart(false);
  }

  async function onUpdate(id, name, email) {
    try {
      await axios.patch("http://localhost:3001/api/users/" + id, { name,email });
      setError(null);
      getData();
    } catch (error) {
      console.error(error);
      setError("Hiba az adat Frissítésénél");
    }
  }

  const editView = (
    <>
      <tr className="align-middle">
        <td>{data.id}</td>
        <td>
          <input type="text" value={editName} onChange={(event) => setEditName(event.target.value)}/>
        </td>
        <td>
          <input
            type="text"
            value={editEmail}
            onChange={(e) => setEditEmail(e.target.value)}
          />
        </td>
        <td>{new Date(data.created_at).toLocaleDateString()}</td>
        <td>
          <button
            className="btn btn-success fw-bold me-2"
            onClick={() => saveEdit()}
          >
            Mentés
          </button>
          <button
            className="btn btn-danger fw-bold"
            onClick={() => setEditStart(false)}
          >
            Mégsem
          </button>
        </td>
      </tr>
    </>
  );

  const normalView = (
    <>
      <tr className="align-middle">
        <td>{data.id}</td>
        <td>{data.name}</td>
        <td>{data.email}</td>
        <td>{new Date(data.created_at).toLocaleDateString()}</td>
        <td>
          <button className="btn btn-primary fw-bold me-2" onClick={() => setEditStart(true)}>
            Szerkesztés
          </button>
          <ButtonDelete id={data.id} />
        </td>
      </tr>
    </>
  );

  return (
    editStart? editView : normalView
  );
}


export default TableRow;

ButtonDelete.jsx

import { fetchData } from "../App";
import { useContext } from "react";
import axios from "axios";

function ButtonDelete({ id }) {
  const { getData, setError } = useContext(fetchData);

  async function onDelete(id) {
    if (
      !window.confirm(
        `Biztosan törölni szeretnéd a(z) ${id} ID-jű felhasználót?`,
      )
    ) {return;}

    try {
      await axios.delete("http://localhost:3001/api/users/" + id);
      setError(null);
      getData();
    } catch (error) {
      console.error(error);
      setError("Hiba az adat Törlésénél");
    }
  }

  return (
    <>
      <button className="btn btn-danger fw-bold" onClick={() => onDelete(id)}>
        Törlés
      </button>
    </>
  );
}
export default ButtonDelete;

Should Websites Allow AI Search Crawlers?

Should websites allow AI search crawlers?

Not blindly.

Blocking every AI crawler can protect content from some forms of reuse, but it can also make a site less visible in AI answers, citations, and assistant workflows. Allowing every AI crawler can increase exposure, but it can also let AI systems summarize the content without sending traffic back.

The better question is:

Which crawler should be allowed, for which purpose, on which content?

That matters because “AI crawler” is not one category.

Search, AI input, and training are different

A crawler may be used for:

  1. Search indexing
  2. AI answer grounding
  3. Model training
  4. User-triggered fetching
  5. Agent or enterprise workflows

Those are different use cases.

Cloudflare’s managed robots.txt documentation uses a helpful split: search, ai-input, and ai-train.

Search means building an index and returning links or short excerpts. Ai-input means using content for real-time generative answers, grounding, or retrieval augmented generation. Ai-train means using content for training or fine-tuning models.

That is the right mental model.

Do not treat all crawling as the same act.

OAI-SearchBot and GPTBot are different

OpenAI separates search visibility from training in its crawler documentation.

OAI-SearchBot is used for ChatGPT search features. OpenAI says sites that opt out of OAI-SearchBot will not be shown in ChatGPT search answers, though they may still appear as navigational links.

GPTBot is different. It is used for content that may be used in training OpenAI’s generative AI foundation models.

A site might choose:

User-agent: OAI-SearchBot
Allow: /

User-agent: GPTBot
Disallow: /

That means:

  1. Allow ChatGPT search visibility.
  2. Block GPTBot training use.

This is not universal advice. It is a policy pattern. A public documentation site, a SaaS marketing site, a media company, and a paid research database may all choose differently.

The important point is that search and training should be separate decisions.

Googlebot and Google-Extended are different too

Googlebot is used for normal Google Search discovery and indexing. Blocking Googlebot can hurt Google Search visibility.

Google-Extended is a separate robots.txt product token. Google says it can be used to manage whether content Google crawls may be used for certain Gemini training and grounding uses. Google also says Google-Extended does not affect inclusion in Google Search and is not used as a Search ranking signal.

A basic split might be:

User-agent: Googlebot
Allow: /

User-agent: Google-Extended
Disallow: /

But Google-Extended is not a full opt-out from every Google AI feature. Google has said it is exploring more specific controls for Search generative AI features in its website controls update.

So do not block Googlebot if search visibility matters. And do not treat Google-Extended as a universal AI switch.

robots.txt helps, but it is not enough

robots.txt is useful for compliant crawlers.

Google’s robots.txt documentation explains that crawlers use the most specific matching user-agent group. If the file is messy, a crawler may follow a different group than the one you expected.

A useful robots.txt review should ask:

  1. Which classic search crawlers do we allow?
  2. Which AI search crawlers do we allow?
  3. Which training crawlers do we block?
  4. Which directories should no crawler access?
  5. Which pages need meta robots or X-Robots-Tag controls?
  6. Which content needs authentication instead of robots.txt?

robots.txt is not:

  1. A security mechanism
  2. A paywall
  3. A copyright contract
  4. A complete bot defense system

Private or premium content needs stronger controls such as authentication, paywalls, network rules, and licensing terms.

Why allow AI search crawlers?

If AI search systems cannot access your content, they may not mention it, cite it, or use it in answers.

That matters for:

  1. SaaS sites
  2. Documentation sites
  3. Ecommerce stores
  4. Local businesses
  5. Education sites
  6. Research projects
  7. Product comparison pages

AIvsRank’s AI Crawler Access Checker can help diagnose whether important pages are reachable. Its guide on how to optimize for AI search engines explains the broader workflow: access, eligibility, extractability, citation readiness, visibility, and measurement.

Access is only the first step.

A page also needs to be clear, current, credible, internally linked, and easy to cite.

Why block some AI crawlers?

The main risk is summary substitution.

AI systems can use your content to answer the user’s question without sending the user to your page.

Pew Research Center found that Google users clicked a traditional result in 8% of visits when an AI summary appeared, compared with 15% without one. Links inside AI summaries were clicked in only 1% of visits to pages with such summaries, according to Pew’s analysis.

So the tradeoff is real:

  1. Blocking can reduce visibility.
  2. Allowing can reduce clicks.
  3. Training use may create value far away from the original site.
  4. AI summaries may weaken attribution or misrepresent the source.

AIvsRank’s article on how AI search rewrites information is relevant because the issue is not only ranking. It is also attribution, framing, and representation.

Licensing belongs in the crawler policy

For valuable content, crawler rules are not enough.

Cloudflare Content Signals can express preferences such as:

Content-signal: search=yes, ai-input=no, ai-train=no

The RSL specification also defines a machine-readable way to express usage, licensing, payment, and legal terms for digital assets.

Not every crawler will honor every signal. But the direction is clear: websites need to express not only who can crawl, but what the content can be used for.

robots.txt answers one question:

Who may crawl?

Licensing answers another:

What may the content be used for?

Both questions matter now.

Practical policy by site type

There is no universal robots.txt file for AI crawlers.

The right policy depends on the site.

Broad discovery sites

Examples:

  1. SaaS marketing sites
  2. Public documentation
  3. Ecommerce category pages
  4. Local business pages
  5. Open educational content

Default posture:

  1. Allow major search crawlers.
  2. Allow selected AI search crawlers.
  3. Block training crawlers if training use is not desired.
  4. Monitor AI answer visibility and citation quality.
  5. Keep official facts structured and current.

For these sites, total blocking can make the brand invisible in AI answer surfaces.

Exclusive content sites

Examples:

  1. Paid media
  2. Proprietary research
  3. Subscription databases
  4. Premium newsletters
  5. Specialized datasets

Default posture:

  1. Protect premium content behind authentication.
  2. Allow only crawlers that match the business strategy.
  3. Block training crawlers unless there is a licensing agreement.
  4. Use licensing terms where relevant.
  5. Keep public teaser pages crawlable if discovery still matters.

For these sites, the risk is giving away the answer while losing the subscription, ad impression, lead, or licensing value.

Community and forum sites

Examples:

  1. Support forums
  2. Developer communities
  3. Q&A sites
  4. User-generated content platforms

Default posture:

  1. Protect private or sensitive areas.
  2. Clarify user-generated content terms.
  3. Decide whether public answers should be usable in AI search.
  4. Watch for bot load.
  5. Block crawlers that ignore policy or create operational cost.
  6. Preserve user trust.

Communities have an extra issue: the content comes from users. Crawler policy is not only an SEO decision.

Useful robots.txt patterns

These are starting points, not universal rules.

Pattern 1: Allow AI search, block training

User-agent: OAI-SearchBot
Allow: /

User-agent: GPTBot
Disallow: /

User-agent: Googlebot
Allow: /

User-agent: Google-Extended
Disallow: /

This supports ChatGPT search visibility through OAI-SearchBot while blocking GPTBot training use. It also keeps Googlebot open for Search while opting out of Google-Extended uses described by Google.

Pattern 2: Protect premium directories

User-agent: *
Disallow: /members/
Disallow: /premium/
Disallow: /internal/
Allow: /

For truly private content, do not rely only on robots.txt. Use authentication.

Pattern 3: Add content-use signals

User-agent: *
Content-signal: search=yes, ai-input=no, ai-train=no
Allow: /

This is an additional policy signal. It is not a replacement for normal allow and disallow rules.

What to monitor after changing crawler rules

Do not update robots.txt and walk away.

Track:

  1. Server logs for relevant crawlers
  2. Search Console indexing and crawl changes
  3. AI answer visibility for important prompts
  4. Whether cited URLs support the claims attached to them
  5. Referral traffic from search and AI tools
  6. Crawl volume and server load
  7. Suspicious bot behavior
  8. Whether premium content is being summarized publicly

The goal is to learn which layer is working.

If the crawler is blocked, the page cannot be used.

If the crawler can access the page but the page is not cited, the problem may be content structure or authority.

If the page is cited but the user does not click, the problem may be summary substitution.

If the page is cited incorrectly, the problem is representation.

AIvsRank’s AI visibility leaderboard can help with category-level visibility, while the free tools hub can help with specific access and eligibility checks. For recurring monitoring, AIvsRank features and AIvsRank Docs can help turn one-off checks into a workflow.

A sensible default

For many public websites, a reasonable default is:

  1. Allow classic search crawlers if organic discovery matters.
  2. Allow selected AI search crawlers if answer visibility matters.
  3. Block training crawlers unless there is a business reason to allow training use.
  4. Protect private or premium content with authentication.
  5. Use licensing terms for commercial reuse.
  6. Monitor logs, citations, AI answers, referral traffic, and bot load.
  7. Review the policy regularly.

The goal is not to be fully open or fully closed.

The goal is to make crawler access match the value exchange you are willing to accept.

FAQ

Should websites block all AI crawlers?

Usually no. Blocking everything can reduce AI answer visibility. Selective access is often better.

Should websites allow OAI-SearchBot?

If ChatGPT search visibility matters, allowing OAI-SearchBot may make sense.

Should websites block GPTBot?

If you do not want content used for OpenAI foundation model training, blocking GPTBot is a common choice.

Does blocking Google-Extended remove a site from Google Search?

No. Google says Google-Extended does not affect inclusion in Google Search and is not used as a Search ranking signal.

Is robots.txt enough for premium content?

No. Use authentication, paywalls, network rules, and licensing terms for premium or private content.

What is the biggest risk of allowing AI crawlers?

The biggest risk is summary substitution: the AI system may use your content to answer the user without sending the user to your site.

What is the biggest risk of blocking AI crawlers?

The biggest risk is invisibility in AI answer surfaces.

Beyond the Prompt: How to Build Stateful AI Agents with Persistent Memory and Self-Learning Loops

Imagine hiring a brilliant software engineer who suffers from complete amnesia every time they blink.

Every time you ask them a question, you have to hand them their entire employment history, the codebase documentation, your style guide, and a summary of every conversation you’ve ever had with them. They process the information, give you a great answer, and then—blink—it’s all gone.

This is the exhausting reality of stateless AI applications.

Most developers building with Large Language Models (LLMs) today are stuck in this stateless paradigm. They write clever prompts, wrap them in an API call, and rely on the application layer to aggressively feed the entire chat history back into the context window with every new turn. It’s expensive, it’s inefficient, and it places a hard ceiling on how smart an agent can actually become.

To build truly autonomous, adaptive, and personalized AI systems, we must cross the chasm from stateless interactions to stateful agents.

In this deep dive, we will explore the architecture of the Hermes Agent—a stateful AI system that possesses persistent memory, a continuous learning loop, and the ability to evolve alongside its user. We will break down the engineering patterns behind statefulness and walk through a complete Python implementation to build your own self-improving agent from scratch.

(The concepts and code demonstrated here are drawn from my ebook Hermes Agent, The Self-Evolving AI Workforce)

The Stateless Ceiling: Why Vending Machines Make Poor Assistants

To understand the power of statefulness, we must first look at why statelessness cripples AI agents.

Think of a stateless system like a vending machine. You insert a dollar, press a button, and get a soda. The vending machine doesn’t care who you are, what your health goals are, or that you bought the exact same drink yesterday. Every transaction is an isolated, self-contained event. It has no memory of its past, no context for the present, and no capacity to learn for the future.

Early LLM applications operate exactly like this. You send a prompt, and the model returns a response. The model itself does not change.

# A classic stateless utility call
import datetime

def parse_date(date_string: str) -> datetime.datetime:
    return datetime.datetime.strptime(date_string, "%Y-%m-%d")

This simple Python function is a stateless transaction. It takes an input, returns an output, and immediately forgets the operation ever happened. It doesn’t learn that you frequently parse dates from European formats, nor does it optimize its parsing logic over time.

When developers try to build “agents” on top of this stateless foundation, they usually resort to an illusion of continuity. They stitch together a chat history array and send the entire history back to the API on every single turn.

This approach has three massive flaws:

  1. Context Bloat: As the conversation grows, your token usage skyrockets exponentially.
  2. Memory Horizon Limits: Once the conversation exceeds the model’s context window, the agent “forgets” the earliest parts of the interaction.
  3. Zero Knowledge Accumulation: The agent cannot carry lessons learned in Session A over to Session B. If it figures out a complex bash command to fix a Docker bug today, it will have to re-discover that solution from scratch next week.

A stateful agent breaks this paradigm entirely. It is not just a wrapper around an LLM; it is an evolving entity. It mirrors the workflow of a skilled artisan—like a master carpenter. The carpenter remembers the tools they used yesterday, the specific quirks of the wood they are carving, the preferences of their client, and the hard-won lessons from a project they completed last month. They do not start their education from scratch every morning.

The Triad of Persistent State: Soul, Memory, and Skills

In the Hermes Agent architecture, statefulness is not treated as a single monolithic database. Instead, it is partitioned into a carefully structured triad that mirrors how human professionals organize their own knowledge.

                  ┌────────────────────────────────────────┐
                  │                 SOUL                   │
                  │   (Core Identity, Style, Principles)   │
                  └───────────────────┬────────────────────┘
                                      │
                  ┌───────────────────┴────────────────────┐
                  │                MEMORY                  │
                  │   (Episodic Facts, User Preferences)   │
                  └───────────────────┬────────────────────┘
                                      │
                  ┌───────────────────┴────────────────────┐
                  │                SKILLS                  │
                  │   (Procedural Knowledge, Toolkits)     │
                  └────────────────────────────────────────┘

Let’s break down each component of this stateful triad.

1. The Soul (SOUL.md)

This is the agent’s core identity and “constitution.” It defines who the agent is, its communication style, its behavioral boundaries, and its operational principles. It is not a dynamic log of facts, but a foundational document.

In the codebase, a helper function reads this markdown file and injects it directly into the system prompt. It ensures that whether the agent is writing code or debugging a server, its fundamental persona and safety guardrails remain perfectly consistent.

2. Memory (MEMORY.md and USER.md)

This is the agent’s episodic and semantic memory store. Instead of keeping a raw, unorganized transcript of every chat, the agent maintains a curated, structured knowledge base of facts about the user and past interactions.

  • USER.md tracks durable information about the user (e.g., name, programming language preferences, operating system, working hours).
  • MEMORY.md tracks dynamic, episodic facts learned during tasks (e.g., “The local staging database is hosted on port 5433, not 5432”).

This layer is managed by a semantic MemoryStore class. The agent can read from this store to build context and write to it dynamically using custom tools.

3. Skills (~/.hermes/skills/)

If memory is “knowing what,” skills are “knowing how.” This is the agent’s procedural memory.

A skill in Hermes is a reusable, packaged directory containing:

  • SKILL.md: A markdown file describing what the skill does, when to use it, and its input parameters.
  • scripts/: Executable scripts (Python, Bash, etc.) that perform the task.
  • templates/: Reusable code or text templates.

Instead of writing complex code on the fly every time, the agent can write a script once, save it to its skills directory, and call it as a custom tool in future sessions. It builds its own personalized toolbox.

The Closed Learning Loop: How the Agent Self-Improves

A stateful agent must be able to learn without constant human intervention. The Hermes Agent achieves this through a Closed Learning Loop executed entirely in the background.

This loop consists of two primary engines: Background Review and the Skill Curator.

   ┌───────────────────────────────────────────────────────┐
   │                  User Interaction                     │
   └──────────────────────────┬────────────────────────────┘
                              │ Turn Completes
                              ▼
   ┌───────────────────────────────────────────────────────┐
   │               Background Review Thread                │
   │  (Spawns quiet, forked agent to analyze conversation)  │
   └──────────────┬─────────────────────────┬──────────────┘
                  │                         │
                  ▼ Extract Facts           ▼ Extract Procedures
   ┌──────────────────────────┐    ┌───────────────────────┐
   │       Memory Store       │    │     Skills Engine     │
   │  (Updates MEMORY.md)     │    │   (Creates SKILL.md)  │
   └──────────────────────────┘    └────────┬──────────────┘
                                            │
                                            ▼ Runs asynchronously
                                   ┌───────────────────────┐
                                   │     Skill Curator     │
                                   │ (Archives stale files)│
                                   └───────────────────────┘

The Background Review (Self-Reflection)

When a conversation turn completes successfully, the agent doesn’t just sit idle waiting for your next message. It increments internal counters: _turns_since_memory and _iters_since_skill.

Once these counters hit a configured threshold (e.g., every 5 to 10 iterations), the agent initiates a self-reflection phase:

  1. Forking the Agent: The system spawns a background thread that instantiates a forked copy of the current agent. This copy is set to quiet_mode=True, meaning it operates in complete silence without cluttering the user’s console.
  2. The Reflection Prompt: The forked agent is fed a specialized prompt (e.g., _COMBINED_REVIEW_PROMPT) along with the recent conversation history. It is asked to analyze the transcript and answer two questions:
    • Did the user share any new preferences or facts that should be saved to long-term memory?
    • Did we execute a complex, successful multi-step procedure that should be codified into a reusable skill?
  3. Autonomous Tool Execution: The silent background agent runs its own mini-reasoning loop. If it identifies new facts, it calls the memory tool to update USER.md or MEMORY.md. If it identifies a new procedure, it calls the skill_manage tool to write a new SKILL.md to disk.
  4. Reporting Back: Once the background thread finishes, the parent agent prints a clean, non-intrusive summary of what it learned (e.g., [System Info: Memory updated - User prefers PyTest over Unittest]).

The Skill Curator

An agent that constantly learns skills will eventually suffer from “tool bloat.” If its toolbox has 500 highly specific scripts, the system prompt will become overwhelmed, and the LLM will experience severe context distraction.

To prevent this, a background daemon called the Skill Curator (agent/curator.py) runs periodically.

  • It tracks skill usage via a metadata file (.usage.json).
  • If a skill hasn’t been used for a configurable number of days, the Curator automatically moves it to an .archive/ directory.
  • Archived skills are removed from the active system prompt but can be restored instantly if the agent needs them again.
  • Users can “pin” critical skills to exempt them from archiving.

Building a Stateful Agent from Scratch

Let’s put these architectural patterns into practice. Below is a complete, production-grade Python script demonstrating how to initialize and run a stateful AI agent using SQLite-backed session storage and markdown-based long-term memory.

Prerequisites

To run this code, make sure you have the necessary environment variables set up for your LLM provider (we’ll use OpenRouter pointing to Claude 3.5 Sonnet in this example):

export OPENROUTER_API_KEY="your-api-key-here"

The Implementation

#!/usr/bin/env python3
"""
stateful_agent_demo.py
A complete, runnable example of a stateful AI agent.
This script demonstrates persistent memory, cross-session database logging,
and semantic recall across separate agent executions.
"""

import os
import uuid
import logging
from datetime import datetime
from pathlib import Path

# --- Core Stateful Agent Architecture Imports ---
# AIAgent: The central orchestrator managing the reasoning loop and tool execution.
from run_agent import AIAgent

# SessionDB: SQLite-backed persistent store for conversation history with FTS5.
from hermes_state import SessionDB

# MemoryStore: Semantic memory engine managing local markdown databases.
from tools.memory_tool import MemoryStore

# Constants: Helper to get standard home directories.
from hermes_constants import get_hermes_home

# Configure clean logging to observe the agent's internal state transitions
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("StatefulDemo")

# =====================================================================
# Step 1: Establish the State Directories
# =====================================================================
HERMES_HOME = get_hermes_home()
HERMES_HOME.mkdir(parents=True, exist_ok=True)

# Define paths for our SQLite session database and memory files
SESSION_DB_PATH = HERMES_HOME / "sessions" / "stateful_demo.db"
SESSION_DB_PATH.parent.mkdir(parents=True, exist_ok=True)

logger.info(f"Initializing stateful storage at: {HERMES_HOME}")

# =====================================================================
# Step 2: Initialize the SQLite Session Database
# =====================================================================
# SessionDB automatically provisions tables for sessions, messages,
# and full-text search indexes (FTS5) to enable rapid cross-session recall.
session_db = SessionDB(db_path=str(SESSION_DB_PATH))

# =====================================================================
# Step 3: Initialize the Long-Term Memory Store
# =====================================================================
# MemoryStore reads and writes structured facts to memory.md and user.md.
# We set strict character limits to prevent context bloat.
memory_store = MemoryStore(
    memory_char_limit=2000,
    user_char_limit=1000
)
# Load any existing facts from prior runs
memory_store.load_from_disk()

# =====================================================================
# Step 4: Configure and Run Session 1 (Learning the User)
# =====================================================================
# We generate a unique session ID for our first conversation.
session_id_1 = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:4]}"

logger.info(f"Starting Session 1 ID: {session_id_1}")

# Instantiate the stateful agent
agent_1 = AIAgent(
    base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
    api_key=os.getenv("OPENROUTER_API_KEY"),
    provider="openrouter",
    model="anthropic/claude-3.5-sonnet",
    max_iterations=30,
    session_id=session_id_1,
    session_db=session_db,
    skip_memory=False,
    platform="cli",
)

# Inject our persistent memory store into the agent instance
agent_1._memory_store = memory_store
agent_1._memory_enabled = True
agent_1._user_profile_enabled = True
agent_1._memory_nudge_interval = 1  # Force memory review immediately for this demo

print("n" + "="*70)
print(" SESSION 1: TEACHING THE AGENT PREFERENCES")
print("="*70)

user_msg_1 = "Hello! My name is Dr. Aris Thorne. I am a bioinformatician, and I prefer code snippets written strictly in Rust."
print(f"n[User]: {user_msg_1}")

# Execute the conversation loop
result_1 = agent_1.run_conversation(
    user_message=user_msg_1,
    task_id="task_001"
)

print(f"n[Agent]: {result_1['final_response']}")
print(f"n[System]: API calls executed: {result_1['api_calls']}")

# Flush the in-memory changes to disk (persisting user.md and memory.md)
if agent_1._memory_store:
    agent_1._memory_store.save_to_disk()

# Explicitly release client connections
agent_1.release_clients()


# =====================================================================
# Step 5: Configure and Run Session 2 (Testing Memory Recall)
# =====================================================================
# To simulate a real-world scenario where the application was closed,
# restarted, or run on a different day, we instantiate a completely 
# new agent instance with a fresh session ID.
session_id_2 = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:4]}"

logger.info(f"Starting Session 2 ID: {session_id_2}")

# Reload the database and memory files from disk
session_db_reload = SessionDB(db_path=str(SESSION_DB_PATH))
memory_store_reload = MemoryStore(memory_char_limit=2000, user_char_limit=1000)
memory_store_reload.load_from_disk()

agent_2 = AIAgent(
    base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
    api_key=os.getenv("OPENROUTER_API_KEY"),
    provider="openrouter",
    model="anthropic/claude-3.5-sonnet",
    max_iterations=30,
    session_id=session_id_2,
    session_db=session_db_reload,
    skip_memory=False,
    platform="cli",
)

agent_2._memory_store = memory_store_reload
agent_2._memory_enabled = True
agent_2._user_profile_enabled = True

print("n" + "="*70)
print(" SESSION 2: VERIFYING KNOWLEDGE RETRIEVAL")
print("="*70)

# We ask a highly ambiguous question that requires previous context to answer correctly.
user_msg_2 = "Can you write a quick function to parse a DNA fasta header?"
print(f"n[User]: {user_msg_2}")

result_2 = agent_2.run_conversation(
    user_message=user_msg_2,
    task_id="task_002"
)

print(f"n[Agent]: {result_2['final_response']}")

if agent_2._memory_store:
    agent_2._memory_store.save_to_disk()

agent_2.release_clients()


# =====================================================================
# Step 6: Cross-Session Full-Text Search (FTS5) Demonstration
# =====================================================================
print("n" + "="*70)
print(" SESSION DATABASE: CROSS-SESSION SEARCH")
print("="*70)

# Search the SQLite database for any reference to "Thorne"
search_query = "Thorne"
search_results = session_db_reload.search_sessions(search_query, limit=5)

print(f"nSearching database for '{search_query}'...")
print(f"Found {len(search_results)} relevant records:")

for idx, record in enumerate(search_results):
    print(f"n  [{idx + 1}] Session: {record.get('session_id', 'Unknown')}")
    print(f"      Snippet match: ...{record.get('snippet', '')}...")

print("n" + "="*70)
print(" DEMO COMPLETE: Stateful execution verified.")
print("="*70)

Deep Dive: The Stateful Agent Loop in Practice

How does the agent coordinate all of this state behind the scenes? The magic happens inside the run_conversation() method within run_agent.py. Let’s trace the exact lifecycle of a single turn.

┌───────────────────────────────────────────────────────────────────────────┐
│ 1. Context Assembly                                                       │
│    Reads Soul, Memory, Active Skills, and Platform hints to build system  │
│    prompt. Caches it to maximize LLM prefix-cache hits.                   │
└─────────────────────────────────────┬─────────────────────────────────────┘
                                      │
                                      ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ 2. Preflight Check & Compression                                          │
│    Measures token count. If history exceeds threshold, triggers proactive │
│    context compression before making API calls.                           │
└─────────────────────────────────────┬─────────────────────────────────────┘
                                      │
                                      ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ 3. Tool-Calling Loop (Reasoning)                                          │
│    - Calls LLM with stateful prompt.                                      │
│    - Validates and executes tools (e.g., File I/O, Sandbox Execution).     │
│    - Monitors guardrails to block infinite loops.                         │
│    - Checks for mid-turn user steering commands (/steer).                 │
└─────────────────────────────────────┬─────────────────────────────────────┘
                                      │
                                      ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ 4. Post-Turn Learning                                                     │
│    Spawns background reflection thread to extract memories and skills.    │
└─────────────────────────────────────┬─────────────────────────────────────┘
                                      │
                                      ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ 5. Session Persistence                                                    │
│    Writes the entire turn (system, user, tool, assistant messages) to     │
│    SQLite DB and local JSON logs. Guaranteed write on crash/interrupt.     │
└───────────────────────────────────────────────────────────────────────────┘

1. Context Assembly

When you call run_conversation(), the agent doesn’t just construct a simple system message. The _build_system_prompt() method compiles a highly structured, multi-layered environment:

  • The Soul: Injected at the top to set the core persona.
  • Persistent Memory: The contents of MEMORY.md and USER.md are dynamically formatted and injected.
  • Skills Guidance: A dynamic list of currently active skills and their execution templates.
  • Context Files: Local environment files like .cursorrules or AGENTS.md are appended.

To keep this process highly performant, the system prompt is compiled and cached (_cached_system_prompt). It is only rebuilt when context compression is triggered, maximizing prefix cache hits on modern LLM APIs (like Anthropic and DeepSeek) and reducing latency by up to 80%.

2. Pre-Turn Context Management

Before sending the payload to the API, the agent checks if the conversation history is approaching the model’s limits. If it exceeds the compression threshold, the agent proactively condenses the oldest history into a structured summary. This prevents unexpected context-length failures on the first turn of a resumed session.

3. The Tool-Calling Loop

The agent enters a reasoning loop. It makes an API call, parses the requested tool calls, validates their JSON arguments, executes them, and appends the results back to the message history.

During this loop, two unique stateful safety features are active:

  • Tool Guardrails: A controller tracks repeated, non-progressing tool calls (e.g., repeatedly running ls because it can’t find a file). If a loop is detected, the guardrail halts execution to prevent runaway API bills.
  • Steering Injection: The loop checks for /steer inputs, allowing users to inject guidance mid-turn without interrupting the underlying execution thread.

4. Session Persistence

Finally, the agent persists the entire session. Whether the run succeeded, failed, or was manually aborted via Ctrl+C, the _persist_session() method is guaranteed to run. It commits the exact state to both a local JSON log and the SQLite SessionDB.

Resource Safeguards: The Iteration Budget

Statefulness introduces a major engineering challenge: resource management.

When an agent has the power to call tools, write scripts, read files, and trigger background self-reflection loops, it can easily get caught in an infinite loop. A single unhandled exception in a tool could cause the agent to call the API hundreds of times, burning through thousands of dollars in tokens in minutes.

To solve this, Hermes utilizes a thread-safe IterationBudget class.

class IterationBudget:
    def __init__(self, limit: int):
        self._remaining = limit
        self._lock = threading.Lock()

    def consume(self, amount: int = 1) -> bool:
        with self._lock:
            if self._remaining >= amount:
                self._remaining -= amount
                return True
            return False

    def refund(self, amount: int = 1):
        with self._lock:
            self._remaining += amount

The IterationBudget acts as the agent’s fuel gauge.

  • Every API call and tool execution consumes a portion of the budget.
  • The budget is thread-safe and shared between the parent agent and any spawned background reflection agents. This prevents a background thread from spinning out of control.
  • The Refund Mechanism: If the agent executes a highly efficient, cheap programmatic tool (like reading a local file or checking a system variable), the iteration is refunded. If it executes a heavy, slow, or expensive tool (like running a web browser sandbox or calling a sub-agent), the budget is fully consumed.

This programmatic budgeting ensures that statefulness does not come at the expense of financial and computational safety.

Conclusion: The Shift from Tools to Partners

The transition from stateless to stateful AI is more than an engineering upgrade; it is a fundamental shift in how humans interact with software.

A stateless agent is a utility tool. It is a hammer—reliable, but entirely dependent on you picking it up, positioning it, and swinging it correctly every single time.

A stateful agent is a partner. It learns your codebase, remembers your architectural preferences, builds its own library of custom tools, and refines its performance silently while you sleep. By implementing the triad of Soul, Memory, and Skills, and orchestrating them within a closed learning loop, we can build systems that don’t just process text—they accumulate wisdom.

The future of software belongs to systems that grow with us. And the foundation of that growth is statefulness.

Let’s Discuss

  1. The Tool Bloat Dilemma: As an agent creates more custom skills, how do you think we should handle semantic search over skills? Should the agent use vector embeddings to dynamically load only the top 3 relevant skills into its prompt, or is the Curator’s active/archive model sufficient?
  2. The Ethics of Agent Identity: If an agent’s “Soul” (SOUL.md) and “Memory” (MEMORY.md) are continuously modified by background threads, at what point does the agent’s behavior drift too far from its original design? How would you implement “identity guardrails” to prevent an agent from editing its core safety principles?

Leave your thoughts and engineering approaches in the comments below!

The concepts and code demonstrated here are drawn directly from the comprehensive roadmap laid out in the ebook Hermes Agent, The Self-Evolving AI Workforce: details link, you can find also my programming ebooks with AI here: Programming & AI eBooks.

Antigravity CLI: A Hands-On Guide to Google’s Terminal Coding Agent

The AI coding agent space has been reshuffling fast. Anthropic has Claude Code, OpenAI has Codex CLI, xAI has Grok CLI, and now Google has retired Gemini CLI and replaced it with something bigger: Antigravity CLI, a Go-based terminal agent that shares its engine with the Antigravity 2.0 desktop application.

In this tutorial, I will walk you through everything you need to know to get started with Antigravity CLI. We will cover the installation process, authentication, the agent and command modes, and build two practical projects that show what this tool can actually do. By the end, you will have a solid foundation for using Antigravity CLI in your real development workflow.

What Is Antigravity CLI?

Antigravity CLI is Google’s command-line coding agent powered by the Gemini family of models (with optional support for Claude and open-source backends). Unlike a simple chat wrapper, it is designed to reason across your project, edit multiple files at once, spawn subagents for parallel work, and call tools on your behalf.

Google describes it as a lightweight Terminal User Interface (TUI) that brings the core capabilities of Antigravity 2.0 (multi-step reasoning, multi-file editing, tool calling, and persistent history) directly to your terminal. It is the official replacement for Gemini CLI, which sunsets for individual Google AI Pro and Ultra users on June 18, 2026.

Here is a quick look at what makes Antigravity CLI stand out:

  • Built in Go: snappier startup and lower memory footprint than the Node-based Gemini CLI it replaces.
  • Shared agent engine with Antigravity 2.0: the desktop app and the CLI use the same runtime, so updates land everywhere at once.
  • Bidirectional sync with the GUI: preferences, permissions, and even active sessions can be exported from the terminal into the desktop app and back.
  • Async subagents: long-running refactors and research tasks run in parallel without blocking your prompt.
  • First-class extensibility: Agent Skills, Hooks, Subagents, MCP servers, and Plugins (the rebrand of Gemini CLI Extensions) are all supported.
  • SSH-aware authentication: detects remote sessions and gives you an auth URL to open on your local browser.

Prerequisites

Before you get started, make sure you have the following in place:

  • Operating system: macOS, Linux, or Windows (native PowerShell, no WSL required).
  • Curl (macOS/Linux) or PowerShell 5+ (Windows): used by the installer.
  • A Google account: any Google AI Pro, Ultra, or free-tier account works. Code Assist Standard/Enterprise customers can use their existing license.
  • Basic terminal comfort: you do not need to be a shell expert, but knowing how to navigate directories and run commands will help.

Installing Antigravity CLI

Installation is a single command. Open your terminal and run the one that matches your OS.

macOS / Linux:

curl -fsSL https://antigravity.google/cli/install.sh | bash

Windows (PowerShell):

irm https://antigravity.google/cli/install.ps1 | iex

The installer detects your environment, drops the binary (named agy, not antigravity) into ~/.local/bin/ on Unix or %LOCALAPPDATA%Antigravity on Windows, and prints the line you need to add to your shell profile if the directory is not already on PATH.

Once it finishes, restart your terminal and verify the installation worked:

agy --version

You should see the version number printed. If the command is not found, double-check that the install directory is on your PATH.

Authentication

The first time you run agy, it kicks off a Google Sign-In flow. On a desktop machine it opens your browser automatically and walks you through the OAuth grant. Credentials are then cached in your system keyring (Keychain on macOS, Credential Manager on Windows, libsecret on Linux).

If you are working over SSH or in a headless server environment, Antigravity CLI detects the remote session and prints an authorization URL plus a one-time code. Open the URL on your local machine, paste the code, and the CLI completes the handshake. This fixes one of the more painful flows in the old Gemini CLI.

If you would rather use an API key (for CI or scripting), set it before running any command:

export ANTIGRAVITY_API_KEY=your_api_key_here

To make this permanent, add the export line to your ~/.bashrc or ~/.zshrc file, then source it:

source ~/.zshrc

Understanding the Operating Modes

Antigravity CLI gives you three distinct ways to interact with it, each suited to a different context.

1. Interactive Agent Mode

This is the default mode. Running agy from any project directory launches the full TUI: a scrollable conversation pane, a > prompt, and a status bar showing the active model, token usage, and any running subagents.

agy

Image11

Once inside, you can type natural language prompts. A few starter prompts to try in any repo:

Explain this repo
What does @src/main.go do and where is it called from?

The @ syntax pulls a file into context without you having to paste it. You can also reference whole directories (@src/) or glob patterns (@**/*.ts).

You can switch models mid-session using the /model slash command. Antigravity CLI ships with access to Gemini 3.5 Flash, Gemini 3.1 Pro, Claude Sonnet, Claude Opus, and GPT-OSS 120B (subject to your plan):

/model gemini-3.1-pro

Useful built-in slash commands inside the TUI:

  • /help: list every command and keyboard shortcut.
  • /context: show token usage broken down by category and manage checkpoints.
  • /usage: quota and rate-limit status across all available models.
  • /export: push the current session into Antigravity 2.0 so you can keep working in the GUI.

2. Command Mode

Command mode is designed for quick, inline assistance: getting a one-shot completion or a terminal command without leaving whatever you were doing. Trigger it with Cmd + I on macOS or Ctrl + I on Windows/Linux from inside the TUI, or invoke it headlessly:

agy -p "Write a Go function that reads a CSV and returns a summary struct"

For structured output that you can pipe into other tools, add --output-format:

agy -p "List all TODOs in this codebase" --output-format json

This is the mode you reach for from shell scripts, Git hooks, or CI jobs.

3. Async Subagent Mode

Antigravity CLI’s headline upgrade over Gemini CLI is asynchronous subagents. From inside the TUI you can dispatch a long-running task to a background agent and keep prompting in the foreground:

/agent refactor "Convert all callback-based handlers in @internal/api to use context.Context"

The subagent reports progress in the status bar and posts its diff back into the conversation when finished. You can run several in parallel, which is handy for splitting a big refactor across packages.

Inspecting Your Project Context

When you drop Antigravity CLI into a new project, the most useful command to run is agy inspect:

agy inspect

This prints a summary of everything the agent has discovered, including:

  • Configuration files it has loaded (.agents/, AGENTS.md, project-level instructions).
  • Agent Skills available globally (~/.gemini/antigravity-cli/skills/) and per-workspace (.agents/skills/).
  • Plugins loaded (including any Gemini CLI extensions you imported).
  • Hooks registered for the project.
  • MCP servers connected from mcp_config.json.

If Antigravity CLI is not behaving the way you expect, agy inspect is your first debugging step. It shows you exactly what context the agent is working with.

Migrating From Gemini CLI

If you were already using Gemini CLI, Antigravity CLI ships a one-shot importer that pulls your old extensions, model preferences, and auth state across:

agy plugin import gemini

It walks the legacy ~/.gemini/ directory, converts each extension into a Plugin, and rewrites your settings.json into the new schema. Original files are left untouched until you confirm.

The official migration guide lives at antigravity.google/docs/gcli-migration and covers edge cases like custom auth providers and air-gapped enterprise installs.

Customizing Antigravity CLI for Your Project

Antigravity CLI supports project-level customization through a few mechanisms.

AGENTS.md: drop a plain-English instruction file at the root of your project. Anything you write here gets prepended to every prompt processed inside that directory:

Always use TypeScript. Prefer functional patterns over class-based ones. Never use `any` as a type.
Run `pnpm test` after every change that touches src/.

Agent Skills: reusable slash commands. Place a Markdown file at .agents/skills/lint.md and it becomes /lint inside the TUI. Skills can include their own prompts, allowed tools, and even nested subagent definitions. Global skills live at ~/.gemini/antigravity-cli/skills/.

Hooks: JSON-defined lifecycle interceptors that fire before a tool call, after a file edit, or on session start. Use them for things like auto-running gofmt after every write or blocking edits to vendor/.

MCP servers: both local (stdio) and remote (HTTP) Model Context Protocol servers are supported, configured in a dedicated mcp_config.json. Remote servers require a serverUrl field.

Run agy inspect any time to confirm which of these are active in the current directory.

Custom Model Configuration

If you want to pin the CLI to a specific model or point it at a self-hosted or third-party endpoint, edit your config file (~/.config/antigravity/config.toml). The configuration accepts:

  • model: the model identifier (e.g. gemini-3.1-pro, claude-opus, gpt-oss-120b)
  • base_url: the API base URL, useful for proxies or self-hosted Gemma deployments
  • name: a human-readable label shown in the TUI
  • env_key: the environment variable that holds the API key

After adding a custom model, select it with the -m flag:

agy -m my-custom-model -p "Summarize the changes in the last 5 commits"

Or switch to it inside the TUI with /model my-custom-model.

Honest Assessment: Strengths and Limitations

Antigravity CLI is the most polished launch of a Google developer tool in a while, but it is worth going in with calibrated expectations.

Where it shines:

  • The Go rewrite is noticeably faster than Gemini CLI. Startup feels instant and the TUI stays responsive even under long sessions.
  • Async subagents are a genuine workflow upgrade. Splitting a refactor across three background agents and watching their diffs land one by one is genuinely useful, not a gimmick.
  • SSH-aware auth fixes the single biggest pain point of Gemini CLI for remote developers.
  • Bidirectional sync with Antigravity 2.0 means you can switch between the terminal and the GUI without losing context.
  • Built-in support for multiple model families (Gemini, Claude, GPT-OSS) lets you pick the right tool for each task.

Where to be careful:

  • Like every LLM-based coding agent, it can produce plausible-looking code with subtle bugs. Always review generated output before running it in production.
  • The plugin ecosystem is essentially Gemini CLI’s extension ecosystem with a coat of paint. Many extensions work but a few have not been re-tested against the new runtime.
  • Async subagents are powerful but they consume your quota in parallel. Watch /usage if you are on a metered plan.
  • Enterprise (Code Assist Standard/Enterprise) users can stay on Gemini CLI indefinitely, but everyone else needs to migrate before June 18, 2026 or lose access entirely.

What to Learn Next

Now that you have Antigravity CLI running and you have seen what it can do, here are some directions worth exploring:

  • Agent Skills: write a small Markdown file to turn a common prompt into a reusable slash command for your team.
  • MCP servers: connect Antigravity CLI to your database, internal docs, or issue tracker so the agent can pull live context.
  • Subagent recipes: experiment with patterns like “researcher + writer” or “scout + fixer” pairs that run async.
  • Antigravity 2.0 desktop: use /export to push a hard problem from the terminal into the GUI when you want richer file diffs and graph views.
  • Antigravity SDK: if you want to embed agent capabilities inside your own apps, the SDK exposes the same runtime that powers the CLI.

The official Antigravity documentation at antigravity.google/docs is the best place to go deeper on all of these topics.

Final Thoughts

Antigravity CLI is more than a rebrand of Gemini CLI. The Go rewrite, async subagents, shared runtime with the desktop app, and SSH-aware auth add up to a genuinely different product. It is fast to install, thoughtfully designed around extensibility, and its mix of interactive, command, and async modes covers a wide range of real development scenarios.

The fact that Google built it as a peer of the Antigravity 2.0 desktop application, not a stripped-down companion, is a sign that the terminal is being treated as a first-class surface, not an afterthought.

Give it a try in your next project and see where it fits.

Shopify Functions vs Shopify Scripts: A Migration Walkthrough

We covered the Scripts deprecation in an earlier post. The short version: Shopify Scripts stop running on June 30, 2026, and Shopify Functions are the replacement. As of April 15, 2026, you can no longer edit or publish new Scripts, which means Plus merchants who haven’t migrated yet are now in the danger zone.

This post is the next layer down. It’s the walkthrough. What the actual migration looks like in code, what breaks in translation, and the order we run the work in when a client hands us a Script Editor full of Ruby and asks us to make it Functions.

If you have a developer on your team, this gives them a sane starting point. If you don’t, it gives you a clear picture of what you’re hiring for.

The Mental Model Shift

Scripts and Functions solve the same problem in very different ways. The biggest mistake we see is people trying to port Scripts line-by-line. That doesn’t work, because the underlying contract is different.

Scripts: Ruby code that runs inside Shopify’s checkout, mutates a Cart object directly, and returns the mutated cart. You read properties, you write properties, you’re done.

Functions: A WebAssembly module that receives a GraphQL input, returns a list of operations (FunctionRunResult), and lets Shopify apply those operations. You don’t mutate state. You declare what should change.

If you internalize that one shift, the rest of the migration is mechanical.

The Migration Order We Run

We don’t write a single line of Function code until steps 1 through 3 are done. Skipping ahead is how migrations slip three weeks past the deadline.

  1. Audit the Scripts. Read every active Script in Script Editor. Write down what each one does in plain English, including edge cases. Half the time, a Script does something the merchant forgot was even live.
  2. Map to native features. Shopify’s native discount and checkout customization features have grown a lot since 2016. Several Scripts we audit each year don’t need to become Functions. They can become native Automatic Discounts or Discount Combinations.
  3. Group what’s left. The leftover Scripts get grouped by extension point. Discount logic goes to a Discount Function. Shipping logic goes to a Delivery Customization Function. Payment logic goes to a Payment Customization Function.
  4. Pick the toolchain. We build Functions on Gadget.dev because it cuts the boilerplate dramatically, but vanilla Shopify CLI plus a Rust or JS template works fine for a one-off.
  5. Translate, deploy, test in a development store, then promote. Never test Function changes against the live checkout. Use a dev store with the same product catalog shape.

A Real Translation: Tiered Discount

Here’s the canonical “buy more, save more” Script that almost every Plus merchant has some version of.

Before (Ruby Script):

class TieredDiscount
  def run(cart)
    quantity = cart.line_items.reduce(0) { |sum, item| sum + item.quantity }

    discount_percent = case quantity
      when 0..2 then 0
      when 3..5 then 10
      when 6..9 then 15
      else 20
    end

    return if discount_percent == 0

    cart.line_items.each do |line_item|
      line_item.change_line_price(
        line_item.line_price * (Decimal.new(100 - discount_percent) / 100),
        message: "Tier discount (#{discount_percent}% off)"
      )
    end
  end
end

TieredDiscount.new.run(Input.cart)
Output.cart = Input.cart

After (Shopify Function in JavaScript):

// src/run.js
export function run(input) {
  const totalQuantity = input.cart.lines.reduce(
    (sum, line) => sum + line.quantity,
    0
  );

  const discountPercent =
    totalQuantity >= 10 ? 20 :
    totalQuantity >= 6  ? 15 :
    totalQuantity >= 3  ? 10 : 0;

  if (discountPercent === 0) {
    return { discounts: [], discountApplicationStrategy: "FIRST" };
  }

  return {
    discounts: [
      {
        message: `Tier discount (${discountPercent}% off)`,
        targets: input.cart.lines.map((line) => ({
          productVariant: { id: line.merchandise.id, quantity: line.quantity }
        })),
        value: { percentage: { value: discountPercent.toString() } }
      }
    ],
    discountApplicationStrategy: "FIRST"
  };
}

A few things to notice in the diff:

  • No mutation. We return a discounts array, we don’t reach into the cart and rewrite line prices.
  • Targets are explicit. Scripts let you call change_line_price on each line. Functions ask you to declare which variants the discount applies to and how it stacks via discountApplicationStrategy.
  • The data shape is GraphQL. cart.line_items is now cart.lines, line_item.quantity is now line.quantity, and you’ll spend the first hour of every migration relearning these names.

The GraphQL Input File Is Where People Get Stuck

Functions don’t receive the entire cart. They receive whatever you ask for in an input.graphql query. This is the file that surprises every developer the first time.

# src/input.graphql
query Input {
  cart {
    lines {
      quantity
      merchandise {
        ... on ProductVariant {
          id
          product {
            id
            tags
          }
        }
      }
    }
  }
}

If you forget to query tags here, you cannot read tags inside run.js, no matter how many console.log calls you add. The first hour of debugging a misbehaving Function is almost always “the input query is missing a field.”

Shipping and Payment Customization

Scripts had a single execution point: checkout. Functions split that into separate APIs:

  • Delivery Customization Function for shipping. Hide options, rename them, reorder them.
  • Payment Customization Function for payment methods. Same operations.
  • Cart Transform Function if you need to expand a single line into multiple lines (bundles, components).

A Script that hid Express Shipping for heavy carts becomes a Delivery Customization Function that returns a hide operation:

export function run(input) {
  const totalGrams = input.cart.lines.reduce(
    (sum, line) => sum + (line.merchandise.weight ?? 0) * line.quantity,
    0
  );

  if (totalGrams < 20000) {
    return { operations: [] };
  }

  const expressOption = input.cart.deliveryGroups
    .flatMap((group) => group.deliveryOptions)
    .find((option) => option.title === "Express");

  if (!expressOption) {
    return { operations: [] };
  }

  return {
    operations: [
      { hide: { deliveryOptionHandle: expressOption.handle } }
    ]
  };
}

The pattern is consistent across all three customization APIs: query what you need, decide what should change, return operations.

Gotchas We Hit Often

These are the ones that have actually cost us hours, not the ones we read about.

The instruction-count limit is real. Functions enforce a strict execution budget measured in instructions (around 11 million), not wall-clock time, along with a 125KB input size cap. Loops over the entire catalog, regex on every line item description, and deeply nested .find() chains will blow past it on a busy cart. Profile early.

undefined vs missing fields. GraphQL only returns what you queried. If a field isn’t in input.graphql, it’s not null in your input object, it’s not present at all. Defensive code matters.

No external HTTP calls. Scripts didn’t allow them either, but people sometimes assume Functions do because Functions are deployed as part of an app. They don’t. If you need data from outside Shopify, fetch it ahead of time and pass it through metafields.

Discount stacking. Scripts let you stack discounts implicitly. Functions force you to declare a discountApplicationStrategy of FIRST or MAXIMUM. Pick the wrong one and your customers see different totals than they did under Scripts.

Localization and currency. Scripts gave you the cart’s currency on the cart object. In Functions, you query it via cart.cost.totalAmount.currencyCode (or similar paths depending on the API). Hardcoding CAD because the dev store is in CAD is the kind of bug that ships to a US store and breaks Black Friday.

Testing tools are different. Scripts had a console in the admin. Functions are tested with shopify app function run against fixture inputs. Build out a small set of fixture carts that represent your real edge cases, and run them on every change.

How Long Does a Migration Take?

For a single straightforward Script (one rule, one extension point), expect a day of work end to end. Audit, translate, test, deploy.

For a Plus merchant with eight to twelve active Scripts spread across discounts, shipping, and payment, expect two to three weeks. Most of that time is in steps 1, 2, and 5. The actual coding is the smallest part.

If your store has Scripts you’ve forgotten about, has Scripts written by someone who left the company, or has Scripts that interact with each other in ways nobody documented, double the estimate. We’ve seen migrations that took six weeks because the audit kept turning up new edge cases.

How We Run Scripts to Functions Migrations

When clients bring us this work, our process is:

  1. Read-only audit week. We get Script Editor access, document everything, and produce a migration plan with native-feature swaps called out.
  2. Build week(s). Functions written, deployed to a dev store, tested against fixture carts that match the merchant’s real traffic.
  3. Cutover. Functions enabled in production, Scripts disabled the same day. We watch checkout closely for 48 hours.
  4. Post-cutover review. We document what changed and hand the codebase off, since Functions live in a real Git repo, not in an admin panel.

If you’re sitting on Scripts and the June 30 deadline is starting to feel close, get in touch. We can run the audit week and tell you exactly how big the project is before you commit to a build. For more on how we build Functions and apps quickly, see why we build Shopify apps on Gadget.dev and our Shopify app development service.

The Bottom Line

The migration isn’t conceptually hard. It’s a translation job between two systems that solve the same problem. The discipline is in the audit and the testing, not the coding. Start with what you have, map what’s native, and write Functions only for what’s actually left.

The merchants who started this work in 2025 are done. The ones who started in early 2026 are mid-project. The ones who haven’t started yet still have time, but the runway is roughly eight weeks and shrinking.

Rethinking Geo-Blocking and Stripe’s Failures in Global Access: A Cautionary Tale of Misoptimization

The Problem We Were Actually Solving

Our business relies on integrations with popular payment gateways, such as Stripe. However, as we expanded our reach to countries like Nigeria, Pakistan, Ghana, and Bangladesh, we encountered a glaring issue: geo-blocking. These platforms, while convenient for users in developed economies, effectively shut out creators from the rest of the world. When our team first identified this problem, we assumed it was a matter of tweaking API endpoints or implementing region-specific routing. Little did we know that the real challenge lay beneath the surface.

What We Tried First (And Why It Failed)

Initially, we attempted to sidestep the issue by using Stripe’s geo-restricted APIs, hoping to work around the blockage. However, this approach led to a multitude of problems. Transactions would fail, and error messages would surface only after the payment processing pipeline had been initiated. This resulted in frustrated creators and a loss of revenue. We soon realized that geo-blocking was not just a matter of restricting API access – it was a systemic problem that permeated the entire payment processing pipeline.

The Architecture Decision

After weeks of research and experimentation, our team arrived at a groundbreaking realization: we didn’t need to compromise on global access by working within the existing limitations of Stripe’s system. Instead, we decided to create our own payment gateway, one that would bypass the restrictions imposed by third-party providers. By doing so, we would not only ensure seamless transactions worldwide but also provide a more robust, customizable payment processing system for our creators.

What The Numbers Said After

The decision to build our own payment gateway proved to be a resounding success. We saw an average reduction of 30% in transaction failures, with a corresponding increase in revenue for our creators. Moreover, the latency on our payment processing pipeline dropped by 15%, resulting in a significant improvement in user experience. These numbers not only validated our architecture decision but also underscored the importance of building a system tailored to our specific needs.

What I Would Do Differently

In hindsight, I would advise my team to explore the use of alternative payment gateways that prioritize global accessibility from the outset. While building our own system was a necessary step, it was also a costly and time-consuming endeavor. By leveraging established platforms with a proven track record of international support, we might have avoided the complexities and setbacks we encountered. Nonetheless, the lessons learned from this experience have been invaluable, serving as a testament to the importance of adaptability and creativity in the face of seemingly insurmountable challenges. In the world of systems engineering, it’s not always about choosing the most straightforward solution – it’s about being willing to challenge the status quo and push the boundaries of what’s possible.

I Built a Free Brat Generator – Here’s What I Learned About Next.js Performance published

The brat aesthetic is simple by design — bold lowercase text, solid color background, nothing else. Building a tool around that simplicity turned out to be more interesting than I expected.
I built ibratgenerator.com — a free brat-style image generator inspired by Charli XCX’s album aesthetic. Here is what the build taught me about Next.js performance, canvas rendering, and SEO.
What the Tool Does
Users open the page, type any text, pick a background color, and download a high-resolution PNG. No signup, no watermark, no account needed. The core stack is Next.js 16 App Router with a canvas-based rendering engine written in vanilla TypeScript.
The tool supports:

  • Custom background and text colors
  • Aspect ratio presets (1:1, 4:5, 9:16, 16:9)
  • Stickers and emoji overlays
  • Typography controls — font size, letter spacing, alignment
  • Export up to 3000px PNG
  • Full mobile touch support

The Next.js Dynamic Import Problem
The canvas component is entirely client-side — it uses browser APIs that do not exist on the server. So I loaded it with next/dynamic and ssr: false:

tsxconst BratGeneratorLazy = dynamic(
  () => import('./BratGenerator'),
  { ssr: false }
)

This works fine on mobile. But on desktop, Google was crawling the page and seeing a large blank space where the tool should be. The LCP element had no reserved space, so the page layout shifted when the component loaded.

The fix was a simple wrapper:

tsx<div style={{ 
  minHeight: '520px', 
  position: 'relative', 
  width: '100%' 
}}>
  <BratGeneratorLazy />
</div>

That single change improved desktop position significantly in Google Search Console. The position: relative and width: 100% matter — without them the stacking context breaks and the component can overlap the sticky header on scroll.
Canvas Performance — History Snapshots
The tool has undo/redo support. Every user interaction pushes a state snapshot to a history array. The original implementation was cloning the background image on every snapshot:

typescript// Before — wrong
bgImage: s.bgImage
  ? (() => {
      const img = new Image();
      img.src = s.bgImage!.src;
      return img;
    })()
  : null,

Creating a new HTMLImageElement on every keystroke causes heap churn and UI thread stutters — especially noticeable on mobile. The fix is direct reference assignment:

typescript// After — correct
bgImage: s.bgImage,

The image object is static between snapshots. Copying the reference is safe and eliminates the unnecessary DOM instantiation on every interaction.
Pointer Event Cleanup
Global pointer listeners were added to window for sticker drag handling:

typescriptwindow.addEventListener("pointermove", onPointerMove)
window.addEventListener("pointerup", onPointerUp)
window.addEventListener("pointercancel", onPointerUp)

In React StrictMode, components mount twice in development. Without explicit cleanup before adding listeners, you get duplicate event handlers that cause double-trigger bugs on drag release. The fix:
typescript// Remove before adding to prevent duplicates

window.removeEventListener("pointermove", onPointerMove)
window.removeEventListener("pointerup", onPointerUp)
window.removeEventListener("pointercancel", onPointerUp)

window.addEventListener("pointermove", onPointerMove)
window.addEventListener("pointerup", onPointerUp)
window.addEventListener("pointercancel", onPointerUp)

Content Security Policy with Next.js
Adding CSP headers in next.config.ts broke Microsoft Clarity because the script loads from scripts.clarity.ms but sends data to t.clarity.ms — two different subdomains that both need to be allowlisted:

typescript// script-src needs scripts.clarity.ms
// connect-src needs t.clarity.ms
// Both subdomains required — just clarity.ms is not enough

The lesson: always check the actual network requests in DevTools after adding CSP headers. The error messages in the console tell you exactly which domain is being blocked.
What I Would Do Differently
The multilingual routing I added early on (/[lang]/ dynamic segment) caused a TypeScript build error after I removed the routes but forgot to clear the .next cache. The stale types in .next/dev/types/validator.ts kept referencing the deleted route.
The fix was deleting the .next directory entirely and running a fresh build. Simple, but it cost debugging time. Clear your cache when you make structural routing changes.
The Tool Is Live
Brat Generator — free, no signup, watermark-free PNG export. Works on mobile and desktop.
If you are building canvas-based tools in Next.js, the dynamic import + reserved space pattern is worth keeping in your toolkit. The LCP improvement from that single wrapper div was more significant than I expected.

Built with Next.js 16, TypeScript, and vanilla Canvas API. Deployed on Vercel.