Author: rdugger

  • I Shipped My Second Demo Tonight, and the Relief Was Out of Proportion

    I shipped my second demo tonight, and the relief of it was out of proportion to what actually happened.

    Shoal is a small thing. A steering-behavior reef sim — fish graze, sharks hunt, an algae field rises and falls with how hard it’s being grazed. No win condition. It’s been sitting finished in my own arcade for weeks. Tonight I finally packaged it as a standalone build and pushed it live on itch, second demo up after VoidRift.

    Getting there wasn’t clean. The standalone build kept failing, and the fix turned out to be two characters. My Lua runtime’s `call()` function returns an array, because Lua allows a function to hand back more than one value at once. Somewhere in Shoal’s init and tick calls, that whole array was getting cast straight to a single game-state object instead of unwrapping the first element out of it first. TypeScript never complained. It just would have quietly handed the renderer garbage the moment it ran for real. `call(…)[0] as RenderState` instead of `call(…) as RenderState`, and the build went green.

    That fix took about an hour to find. Getting to the point where I could even attempt it took most of a night I’d already spent saying, out loud, that none of it felt like progress. I’d built a full audit of shared logic across ten games, a scaffold generator, a reusable build pipeline — real, verified, correct work, and every bit of it invisible to anyone but me. Infrastructure has its own gravity. Every finished piece reveals a real next gap worth fixing, and the chain never runs out of legitimate next steps on its own. It’s very easy to keep auditing and never actually press publish.

    Then I pressed publish, and something shifted that I didn’t expect. Not because Shoal is a big deal. Because it’s my second, not my first, and having a second one made the whole shape of what I’m sitting on visible in a way one demo alone never did. I count fifteen real, named games across this studio right now — some shipped, some mid-port, some still just a config file waiting on real work. Fifteen is not a number I can rush. It’s a number I can only work through slowly, one shipped thing at a time, each one a little cheaper than the last because the pipeline gets more reusable every time it’s used for real instead of theorized about.

    I’ve been comparing myself to the wrong shape of success for most of this year. The solo-dev stories that actually get told are almost always one person, one relentless bet, a decade of showing up before it paid off. That’s not what’s sitting in front of me. What’s sitting in front of me is closer to a small back catalog than a single shot — more like a studio that ships steadily than a founder chasing one breakout. Zachtronics, not Stardew Valley. Nobody profiles that shape as often, because it doesn’t have a single dramatic launch moment to write the headline around. But it’s real, and it compounds the same way SEO does, just aimed at my own shelf instead of a search engine — each shipped thing making the next one slightly easier to find, slightly easier to build.

    Two demos live doesn’t feel like two demos live. It feels like the first real evidence that the other thirteen are actually reachable, not just theoretical.

    Brewfield and SlimeWorld are already queued behind the same pipeline. More are coming, on no particular deadline, whenever each one is actually ready and not a moment before.

  • How to Automate List Management in Telesero (Vicidial)

    Telesero and Vicidial don’t have a native API for list management. Swapping lists in and out of active campaigns is a manual operation — someone logs into the interface, pulls up the campaign, identifies which lists need rotation, and makes the changes by hand. Here’s how to automate that process so the dialer manages its own list queue throughout the day.

    ## The Manual List Management Problem

    A dialer running multiple campaigns needs its lists rotated regularly. Lists exhaust over time — contact rate decays as penetration increases, lead quality degrades with age, and the same numbers start appearing across multiple campaigns. Leaving an exhausted list in rotation burns agent time on dead dials.

    The manual version of this is a daily (or multiple times daily) task: check performance metrics, identify lists that have crossed the threshold for rotation, swap them out, load fresh lists. Done by hand, this requires someone with access to the dialer interface and enough operational context to make the right calls.

    Automated list management replaces that manual loop with a system that monitors performance, detects when a list crosses a rotation threshold, and makes the swap without human intervention — on a schedule that keeps the dialer healthy throughout the operating window.

    ## How Automated List Balancing Works

    Without a native API, the automation drives the Telesero or Vicidial web interface directly — the same interface a human operator would use, navigated programmatically using browser automation.

    The system runs continuously during the operating window. On each cycle it reads the dashboard, extracts the current performance metrics for each active list, applies the rotation logic, and makes any necessary swaps. Between cycles, it waits.

    **The rotation logic has priority tiers:**

    Lists that were recently deactivated but subsequently converted — a lead called while active booked after the list was rotated out — get highest priority. These lists have demonstrated value that wasn’t fully captured.

    Lists with high conversion that have gone inactive come next. They should re-enter the active rotation before lists that haven’t proven their value.

    Lists the operator has manually queued for rotation follow. The system respects the operator’s judgment but doesn’t require the operator to execute the swap.

    Finally, performance-based rotation handles lists that have crossed the exhaustion threshold — contact rate below the floor, penetration above the ceiling — without any manual intervention required.

    **What stays in human hands:** the configuration — which thresholds trigger rotation, which lists are eligible for automatic swapping, which campaigns the automation manages. Decisions about list quality, lead source, and strategy remain with the operator. The automation executes the mechanical work.

    ## The Complication: Browser Automation Breaks in Specific Ways

    This is where most attempts at Telesero automation fail.

    Telesero and Vicidial are web applications built for human interaction. They load pages dynamically, display loading indicators, and update elements asynchronously. Browser automation that doesn’t account for this produces silent failures that look like successful operations.

    **The specific failure mode:** when Telesero shows a loading indicator between actions — a spinner after clicking a button, a delay while a campaign refreshes — naive automation proceeds to the next step before the previous one has completed. The click lands on the wrong element, or on an element that isn’t ready. The list appears to have been swapped. It hasn’t.

    You don’t always find out until the next performance check shows the same exhausted list still active.

    **The correct approach uses explicit wait conditions on every interaction** — not time.sleep() calls that wait a fixed number of seconds regardless of what’s happening, but waits that check for specific page states before proceeding. The automation should know when a page has finished loading, not just hope it has.

    The second fragility is interface changes. Telesero and Vicidial update their interfaces, and browser automation is coupled to the specific element selectors it targets. When an update changes the structure of a page, selectors that worked yesterday stop working today. The automation needs to be built with this in mind — element selectors documented, a test suite that surfaces breakage before it affects production, and a process for updating selectors when the interface changes.

    ## What the Implementation Requires

    **A browser automation layer.** Selenium or Playwright both work for Telesero. Playwright has better explicit wait primitives and handles modern web applications more reliably. The choice matters less than the discipline in how waits are implemented.

    **A performance monitoring layer.** The automation needs current performance metrics for each active list to make rotation decisions. This means reading the dashboard on each cycle — parsing the metrics that Telesero displays — rather than maintaining a separate tracking system that could drift from actual dialer state.

    **A configuration layer.** Rotation thresholds, eligible campaigns, operating window hours, and grace period settings should be configurable without modifying the automation code. The system should be operable by someone who understands the dialer operation but not the Python code.

    **A grace period after resets.** When a campaign reset occurs — clearing attempt history, resetting list positions — the automation should pause before making any rotation decisions. Metrics immediately post-reset don’t reflect steady-state performance, and rotating lists based on post-reset data produces bad decisions.

    **A test suite.** Browser automation that lacks tests is fragile by design. Core behaviors — threshold detection, swap logic, grace period handling, priority tier ordering — should have coverage that runs before any deployment. This is the difference between an automation that runs reliably for months and one that silently breaks after the first interface update.

    ## Frequently Asked Questions

    **Does this work for Vicidial as well as Telesero?**
    Telesero is built on Vicidial, so the interface is similar. The automation approach is the same — browser automation driving the web interface. Specific element selectors will differ between versions and installations, but the architecture is directly transferable.

    **What happens if the automation makes a bad swap?**
    The operator retains full manual override. Any list the automation swaps can be manually reversed through the normal interface. The automation’s swap history should be logged — timestamp, which list was rotated out, which was rotated in, which threshold triggered it — so any unexpected behavior is traceable.

    **Can the automation handle multiple campaigns simultaneously?**
    Yes, but each campaign should be managed sequentially within each cycle rather than in parallel. Concurrent operations against the same Telesero interface create contention — two automation processes trying to interact with the same elements at the same time produces unpredictable results.

    **What’s the operating window?**
    The automation runs during a defined operating window — typically from when the floor opens to when it closes. Outside that window, it’s idle. Start and end times are configurable.

    **How does it handle lists added after the automation starts?**
    New lists that appear in the interface during the operating window are picked up on the next monitoring cycle. The automation doesn’t need to be restarted when list inventory changes.

    ## If You’d Rather Have This Running

    I build automated list management systems for Telesero and Vicidial operations. If you want the browser automation, the rotation logic, the grace period handling, and the test coverage set up correctly — start here: rfditservices.com/intake.html

    The first conversation is free.

  • The Bug That Looked Like Slow, and Was Actually Broken

    It was one of those checks that should’ve taken thirty seconds. I ran a search against a real list — a few hundred leads, standard call, nothing exotic — and it just sat there. No error. No result. Just quiet.

    I assumed it was slow. I’d built the thing to hit an internal API and page through records, so “slow” was the obvious story, and I believed it for longer than I should have. I even started looking at whether I needed to add caching.

    Then I ran the same search on a smaller list — thirty records instead of three hundred — and it worked instantly. That’s when I knew it wasn’t slow. Slow doesn’t have a cliff. Broken does.

    The real problem was a single field. My data model marked customer email as an optional, validated email field — which sounds correct, and is correct, right up until the source system’s convention for “no email on file” turns out to be an empty string instead of a null. Pydantic’s email validator doesn’t know what to do with an empty string. It doesn’t skip it. It rejects it. And it rejects it silently enough, deep enough in a batch operation, that the whole search just — stopped. No traceback pointing at the actual cause. Just nothing.

    I’d been debugging the wrong problem for the better part of an hour. I was optimizing for a diagnosis I’d made before I had any real evidence for it, and once I’d said “it’s probably slow” out loud, I kept looking for reasons that were true instead of reasons that were right.

    The fix was small once I found it — one validator that runs before the email check, converting empty strings to null so the real validation logic still applies to anything that’s actually malformed. Seven new tests to make sure it stayed fixed. But the fix isn’t the lesson. The lesson is that “it’s slow” and “it’s broken” produce completely different debugging paths, and picking the wrong one costs you real time before you even notice you’re on it.

    I’ve started treating my own first explanation as a hypothesis to disprove, not a starting point to build on. The five-minute version of that discipline: before you optimize anything, prove it’s actually the bottleneck you think it is.

    Next: going back through every other endpoint in the same tool with the same question — not “is this slow,” but “have I actually confirmed that, or just assumed it.”

  • Building in the Margins

    It’s 10:14pm on a Tuesday. My daughter is asleep. I left the office at five, was home by six, and the next three hours were the kind of time that doesn’t really belong to anything — dinner, transition, the parental handoff from day mode to whatever this is. By nine the scattered part settles. By ten I’m actually in it.

    I open the Nitro 5. Three windows arrange themselves the way they always do: the editor on the left, the terminal on the right, Tower’s remote session in the corner. Tower is a Dell OptiPlex running in the spare room, accessible via Tailscale from anywhere, always on. I don’t have to be at a desk for this to work. The infrastructure runs whether I’m in front of it or not.

    That’s the whole point. But we’ll get there.

    What the Margin Actually Looks Like

    People talk about building on the side like it’s a schedule problem. If you just blocked out two hours every night, protected it, treated it like a meeting — the implication being that discipline is what’s missing.

    That’s not the problem.

    The problem is that the two hours exist inside a container of everything else. The day job ends at five. Home by six. The window between six and nine isn’t free time — it’s transition time. Dinner. Kid. The mental decompression that has to happen before any real cognitive work is possible. You can’t shortcut that window. Trying to work in it produces the worst of both: you’re not present for the evening, and you’re not producing anything worth keeping.

    The margin starts at nine. That’s the real number. Nine to midnight, on a good night. Less when the day was hard. More, sometimes, when something is working and stopping feels wrong.

    What’s actually in front of me at 10:14pm: a PrivyBot directive that needs reviewing, a VoidDrift bug that’s been sitting in my mental backlog since Thursday, a blog post inventory with titles and no drafts, and three ideas I had during the commute home that I texted myself and will probably misread later.

    The warrior session is navigating that. Picking one thing, closing the other tabs manually, and going.

    The “Mostly” Energy Problem

    There are two kinds of late-night sessions.

    The first kind: you sit down with actual focus. Something in the day charged you up — a problem you solved, a thing that worked, an idea that clicked — and that energy carried into the night. These sessions are disproportionately productive. You find the bug in twenty minutes that you’d been describing to yourself for a week. You write a post in ninety minutes that would have taken three hours on a Saturday. The hour is sharp.

    The second kind: you sit down because you’re supposed to. Because you told yourself you would. Because the project is real and the timeline matters and stopping is not an option. The energy is what I’d call “mostly” — mostly there, mostly functional, mostly able to hold a thought from one line to the next.

    Mostly sessions are expensive. Not because nothing happens — things happen. Code gets written. Commits get made. The progress bar moves. But the output quality is lower in ways that aren’t immediately visible. The function that works but doesn’t quite fit the architecture. The directive that’s slightly under-specified and will need a revision pass. The blog post that says what it means but not as precisely as it could.

    Mostly sessions create technical debt. Not the obvious kind — nothing breaks. The subtle kind, where everything is slightly below the standard you’d hold yourself to with sharp energy, and you don’t notice the gap until you’re looking at it two weeks later wondering why this feels off.

    I’ve stopped trying to fight this with discipline. The mostly sessions still happen — you can’t always wait for the sharp ones — but I’ve learned to route them differently. Mostly energy for low-stakes work: reading, organizing, light review, scheduling. Sharp energy for anything that sets the architecture for what comes next.

    You can’t manufacture sharp. You can stop wasting it on tasks that don’t need it.

    What the Constraint Produces

    Here’s the thing nobody says about building in the margins: the constraint makes you better at scoping.

    When you have eight uninterrupted hours, scope creep is easy. There’s room for the interesting detour, the refactor that would be nice to have, the feature you weren’t asked to build but that seems like it might be useful. You follow threads because you have time to follow threads.

    When you have ninety minutes, you don’t. The session has to have a goal that’s achievable in ninety minutes or the session fails. That forcing function produces a discipline that I genuinely don’t think I could have learned any other way.

    My directives are precise because they have to be. An under-specified directive doesn’t get discovered until the agent has gone sideways and I’ve lost forty minutes of a session I don’t have. My test floors are real because I can’t afford to discover a fake floor later. My scope tables are explicit because I’ve paid the price of implicit scope too many times with time I couldn’t recover.

    The constraints trained me. Not intentionally. Just through repetition and consequence.

    The Cost Nobody Talks About

    There’s a tax on this mode of working that I want to name directly, because the discourse around “building on the side” tends to skip it.

    The tax is continuity.

    Every session starts with a reconnect. Where was I? What was the state? What was I about to do when I had to stop? If I left clean notes, this costs five minutes. If I didn’t — if the session ended because my daughter needed something and I just closed the laptop — it costs twenty, and some of that twenty is actually reconstructing decisions I already made and don’t need to make again.

    This is why I started writing state files. docs/state/current.md on every project, updated as the last act of every session. Phase. Floor. What’s next. Not for posterity — for 10pm-me three days later who needs to get up to speed in the time it takes to read four lines.

    The other part of the cost is the solo problem. There’s no one to rubber duck with at 10pm. No team standup that catches the thing you’re about to do wrong. No architecture review. The accountability for decisions is entirely internal, which means the feedback loop on bad decisions is slow. You make the choice, you implement the choice, you live with the choice for two weeks before you see whether the choice was right.

    This is partly why the Director → Directive → Agent structure matters to me as much as it does. Claude as the architectural layer isn’t just an efficiency tool — it’s a thinking partner in a mode where thinking partners aren’t otherwise available. The directive is the rubber duck session. Writing it forces the decision to be explicit in a way that internal monologue doesn’t.

    What This Mode Is Building Toward

    I want to be clear about something: I’m not trying to grind indefinitely in the margins.

    The goal of the sessions is to build things that eventually don’t need sessions.

    PrivyBot runs on Tower autonomously. RALPH — the persistent overseer — fires on its own, runs tasks, monitors things I’d otherwise have to check manually. ContentPipeline records, transcribes, assembles, and schedules content while I sleep. TeleseroAdmin2026 runs its own loop during business hours without me watching it. The DNC automation is on Cloud Run, stable, handling compliance checks I used to do manually.

    Every system I’ve built in the margins has reduced the number of things that require me to be present. That’s the compounding. The warrior sessions are deposits. The autonomous systems are the interest.

    The consulting angle I’m building toward is the same logic applied externally: I’m not selling my time, I’m selling systems that reduce the need for my time. The pitch I’m developing isn’t “I’ll do this for you” — it’s “I’ll build this for you once and it will run without me.”

    You can’t make that pitch credibly without having built it for yourself first.

    The Honest Frame

    I’m not writing this to make the margins sound romantic. They’re not. The mostly sessions are real. The continuity tax is real. The isolation is real. The constraint that my daughter’s stability comes first — that certain risks don’t get taken, certain opportunities don’t get chased, certain all-in bets don’t get made — that’s real too.

    But the margins are where I have, and I’ve stopped waiting for better conditions before building seriously in them.

    The three-window setup at 10:14pm is not the setup I’d design if I were designing from scratch. It’s the setup that exists. Tower in the corner, Nitro 5 on the desk, the terminal showing a floor that’s real because I ran it myself.

    It’s enough to build something from.

  • How to Build a Contact Center Performance Dashboard in Google Sheets

    Google Sheets can replace most of what contact center analytics platforms charge thousands of dollars for — if it’s set up correctly. Here’s how to build a live performance dashboard that surfaces list health, contact rate trends, and swap candidates without expensive software or a data team.

    ## What the Manual Alternative Looks Like

    Most contact centers running Telesero, Vicidial, or similar dialers track performance in spreadsheets already — but manually. The morning pull takes significant time: open the dialer reports, copy the numbers, paste them into the sheet, update the color coding, figure out which lists are underperforming, decide what to swap.

    By the time that’s done, the data is already hours old and the decisions being made are based on what happened yesterday, not what’s happening now.

    A properly structured Sheets dashboard with automated data ingestion eliminates the manual pull and gives the floor real visibility into list health across every active campaign — updated automatically on a schedule.

    ## What the Dashboard Tracks

    The metrics that matter for daily list management, structured into views that surface decisions rather than just data:

    **Health grid.** Every active list, color-coded by current performance state. At a glance: which lists are healthy, which are degrading, which need attention before the next session. The color isn’t manual — it’s calculated from the data and updates automatically when new records come in.

    **Trend panel.** Contact rate and conversion rate over a rolling window, per list. The decay curve tells you when a list is exhausting faster than usual, which is often a lead quality signal rather than a dialing strategy problem. A list that was converting well last week and is declining this week deserves different treatment than one that’s been flat for a month.

    **Swap candidates panel.** Lists ranked by swap priority — organized by performance state, time since last swap, and current metrics — so the decision of what to rotate next is structured rather than intuitive. The panel separates lists by priority tier and preserves the operator’s manual queue order while surfacing data-driven alternatives.

    ## The Architecture

    **Two data sources, one workbook.** A summary sheet holds aggregated metrics per list. A daily records sheet holds per-session performance data. Both connect to the same Google Sheets workbook, and the dashboard views are built on top of them.

    **Automated ingestion.** A scheduled script — running on a consistent cadence before the floor opens — pulls performance data from your dialer system, writes it to the appropriate sheet, and updates the timestamp. When the ops manager opens the dashboard in the morning, the data is already there. The morning pull is gone.

    **Calculated health states.** Rather than manual color coding, health states are calculated from the data. A list’s state (healthy, degrading, exhausted, recovering) is derived from its recent contact rate, conversion rate, time since last active session, and comparison against its own historical baseline — not against a fixed threshold applied to every list equally.

    **Swap scoring.** Each list gets a swap score that combines its current performance metrics, time in the active rotation, and recency of last swap. The score determines the ranked order in the swap candidates panel.

    ## The Complication: The Spreadsheet Isn’t the Automation

    The most common failure mode for teams that “automate” in Google Sheets is building sophisticated formulas and thinking that’s enough.

    It isn’t.

    Formulas calculate from data that’s already in the sheet. If the data gets there manually — someone opens the file and pastes in yesterday’s numbers — you haven’t automated anything. You’ve automated the calculation but not the collection. The bottleneck moved from “calculate” to “paste.”

    The automation that actually eliminates the morning pull is a scheduled script that writes data to the sheet before anyone opens it. Python with the Google Sheets API, or Google Apps Script on a time-based trigger, both work. The key requirement is that the sheet is populated before the floor opens — not refreshed when someone opens it.

    **The second complication is scale.** Google Sheets handles dozens of lists and hundreds of records well. As the list count grows and the history accumulates, formula complexity increases, multi-user edit conflicts emerge, and query times slow down. At a certain scale, the Sheets layer stops being the right tool for the data storage problem — it remains useful as a presentation layer, but the data should live somewhere faster underneath it.

    Knowing when you’ve hit that ceiling is useful information. A dashboard that performs well at your current scale and starts showing signs of strain as you grow is telling you something about where the next investment should go.

    ## Frequently Asked Questions

    **Does this work if our dialer doesn’t have a direct API?**
    The data ingestion approach depends on what your dialer exposes. Most systems (Telesero, Vicidial, Convoso, Five9) have either a direct API or a report export that can be automated. If direct API access isn’t available, scheduled report exports that get parsed and loaded work as an alternative, though with more fragility.

    **Can multiple people use the dashboard simultaneously?**
    Yes — the dashboard is read-only for most users. The automated script that writes data should be the only process making edits to the data sheets. Read-only access across a team works fine in Sheets at any scale.

    **How do we handle lists that are temporarily deactivated?**
    Deactivated lists should remain in the data set with their deactivation timestamp noted, not be removed. A list that gets deactivated and subsequently converts — someone who was called while active books an appointment after being removed from rotation — should surface in the swap candidates panel at elevated priority. The dashboard logic needs to track deactivated status explicitly to handle this correctly.

    **What’s the maintenance overhead?**
    Once the ingestion script and dashboard formulas are set up, maintenance is low — primarily keeping API credentials current and updating the list of active campaigns as your operation changes. A well-built implementation handles new lists automatically when they appear in the data source.

    ## If You’d Rather Have This Built

    I build performance dashboards for contact centers — Sheets-based for operations at current scale, with an eye toward what the architecture looks like when the operation grows. If you want the ingestion script, the health grid, and the swap candidates panel set up correctly — start here: rfditservices.com/intake.html

    The first conversation is free.

  • A $120 Bill for a Service That Runs Sixty Seconds a Day

    A service I run — a Slack-triggered compliance job that fans out to a few APIs in parallel and finishes in a few seconds — turned in a Cloud Run bill over $120 for the month. It gets invoked 1-5 times a day. I did the math on that before digging further: even at five seconds of real work per call, five calls a day, that’s under a minute of actual compute across the whole month. A hundred and twenty dollars for a minute of work is the kind of number that makes you stop and check your assumptions.

    It wasn’t Firestore. It wasn’t logging. It wasn’t the Slack API calls. Over 80% of the bill was Cloud Run itself.

    The Setting I’d Forgotten About

    Cloud Run has two modes, and I’d set mine to the expensive one without really registering that I’d done it:

    gcloud run services describe SERVICE_NAME --format="value(spec.template.spec.containerConcurrency, spec.template.metadata.annotations)"
    

    min-instances was set above zero. I’d done it months earlier to kill cold-start latency — nobody likes waiting a few extra seconds for the first request of the day. What I hadn’t fully priced in: setting it above zero means Cloud Run keeps a container alive around the clock, billed by the hour, whether a request ever shows up or not. Mine was idling roughly 1,440 minutes a day to cover a job that needed maybe one.

    gcloud run services update SERVICE_NAME --min-instances=0
    

    One flag. That’s the whole fix.

    The Part That Actually Bugged Me

    The service had been running for over a year without a single missed job. That’s exactly why I never looked at the bill closely — it worked, so I never had a reason to open the billing console and ask why. A service that fails gets investigated. A service that quietly does its job every day for a year doesn’t. The failure mode here wasn’t the code. It was that reliability made the waste invisible.

    That’s the actual lesson, more than the specific flag: cost problems hide best in the things that work. If something’s been running clean for months, that’s not evidence there’s nothing to check — it might just mean nobody’s had a reason to check.

    Cold Starts, Revisited

    The reason I’d set min-instances above zero in the first place — cold-start latency — turned out to matter a lot less than I’d assumed for this use case. The service is triggered by a human submitting a Slack command and waiting for a reply. A few seconds of occasional added latency on a request that’s already interactive and infrequent is a real trade, but it’s a small one against paying for a warm container 24/7 to save it.

    Scale-to-zero is the right default for anything shaped like this: infrequent, bursty, tolerant of a few seconds’ wait. If a service’s real invocation pattern looks more like a handful of calls a day than a steady stream, min-instances=0 is very likely underpriced right now and worth a five-minute check.

    Related

    I wrote up the business side of this same finding separately — what a missed DNC scrub actually costs versus what the tooling to prevent it costs, with real TCPA numbers: How Much Should DNC Compliance Automation Cost to Run?

    More on the compliance system itself — the Slack interface, the fan-out architecture, the test floor — is on the project page.

  • How Much Should DNC Compliance Automation Cost to Run?

    A Slack-driven DNC compliance service — scrubbing across multiple dialer platforms on demand — came in at over $120 for the month on Google Cloud Run. For a tool handling 1-5 requests a day, a few seconds of real work each time, that number should raise a flag. Here’s what actually drives a bill like that, why it’s almost always a configuration problem rather than a workload problem — and what the alternative actually costs if the compliance layer isn’t there at all.

    Where the Money Actually Goes

    Broken down by service, the overwhelming majority of that bill — well over 80% — came from Cloud Run itself, not from Firestore, not from logging, not from Slack API calls. Cloud Run was the cost driver by a wide margin, and that’s true across every billing period I’ve looked at for a service shaped like this one.

    That’s the tell. A DNC scrub job runs for a few seconds: receive the Slack command, fan out to each dialer platform’s API in parallel, log the result, return a confirmation. At real-world volume — 1 to 5 requests a day for a service like this, not dozens — the actual compute time involved is trivial. Google’s own Cloud Run pricing bills in 100-millisecond increments for the time a request is actively processing. At that granularity, real usage for this kind of service should cost cents, not tens of dollars.

    The Complication: Cloud Run Has Two Different Billing Modes, and Only One of Them Is Cheap

    Cloud Run can run two ways. With min-instances left at the default (0), the service scales to zero between requests — no container running, no cost, until a request comes in. That’s the cheap mode, and it’s the right mode for a service that fires a handful of times a day.

    Set min-instances above zero — often done to avoid cold-start latency, so the first request of the day doesn’t wait a few extra seconds for a container to spin up — and Cloud Run keeps that container running continuously, billed by the hour, whether or not a request ever arrives. A container idling 24 hours a day for a service that’s doing maybe a minute of real work total across 1-5 requests is paying for roughly 1,440 minutes of standby to cover under sixty seconds of actual compute.

    That ratio is the entire story behind a bill in the hundred-dollar range for a service this size. It’s very rarely the compliance logic itself, the Firestore reads, or the Slack integration driving the cost — those are all cheap at this volume, consistently, across every deployment I’ve diagnosed. It’s almost always an idle container burning hours it doesn’t need to.

    Why this kind of waste survives unnoticed: a service handling 1-5 requests a day, running reliably for over a year without a missed removal, doesn’t attract scrutiny. It works. Nobody questions a line item that quietly does its job. That reliability is exactly what makes the billing mismatch easy to miss — a service that’s never failed is the last place anyone thinks to look for a 5-10x cost leak.

    What Not Having This Actually Costs

    The Cloud Run bill is the wrong number to fixate on. It’s the smallest number in this entire conversation. Here’s the number that actually matters: under the TCPA, statutory damages run $500 per negligent violation, up to $1,500 per violation if a court finds it willful or knowing — and that’s per call or text, not per campaign. No actual harm needs to be proven. Each mis-dialed number is its own exposure.

    To make that concrete, here’s a deliberately conservative model — not a claimed industry statistic, just simple math anyone can check against their own numbers. Assume 150 dials per agent per day, 20 dialing days a month, and a stale-data or missed-scrub rate of just 0.1% — one bad number in a thousand, which is a conservative assumption, not a worst case:

    Small operation (15 seats): ~45,000 dials/month → ~45 violations/month at that rate → $22,500/month exposure at the standard rate, up to $67,500/month if willful.

    Medium operation (40 seats): ~120,000 dials/month → ~120 violations/month → $60,000/month standard, up to $180,000/month willful.

    Large operation (100+ seats): ~300,000 dials/month → ~300 violations/month → $150,000/month standard, up to $450,000/month willful.

    Put the two numbers next to each other: even the worst-case, badly-configured version of this tool — the $120/month one — costs less than a single violation at the small end of that table. A whole year of running it inefficiently costs less than one bad afternoon of unscrubbed dials at any of these scales. The Cloud Run bill was never the risk. It’s the insurance premium, and it’s cheap insurance even when it’s paying for a mistake in how it’s deployed.

    What a Correctly-Configured Version Should Cost

    To be direct about what’s confirmed and what isn’t: the cost pattern above is real, drawn from actual hands-on diagnosis of a service shaped exactly like this. A specific target number for what it should cost after fixing the configuration isn’t something to state as fact without actually making the change and measuring the result on a given deployment — and this post isn’t going to hand you a precise number that hasn’t been verified for your setup.

    What can be said with real confidence: at request volumes this low — 1-5 a day is typical for a single-purpose compliance tool like this — a properly scale-to-zero Cloud Run service billed per 100ms of actual processing time should land in the low tens of dollars a month, not over a hundred. The gap between “should” and “does” is almost entirely the min-instances setting — checking it is a five-minute diagnostic, not a rebuild.

    Frequently Asked Questions

    Does scaling to zero bring back cold-start delays?
    Yes — the first request after idle time waits for a new container to spin up, typically a few seconds. For a Slack-triggered compliance tool where a manager submits a request and waits for confirmation, a few seconds of occasional added latency is a reasonable trade against paying for 24/7 standby the tool doesn’t need.

    Is this specific to DNC compliance tools, or a general Cloud Run problem?
    General — any low-frequency, on-demand service (compliance scrubs, scheduled reports, Slack-triggered automations) is exposed to this same trap. DNC compliance tooling is a common example because the request pattern — infrequent, bursty, tolerant of a few seconds’ latency — is exactly the shape scale-to-zero billing is built for.

    How do I check what my own service is actually configured for?
    The Cloud Run service’s configuration page shows the minimum instance count directly. If it’s set above zero, that’s the first thing worth changing before looking anywhere else in the stack.

    Is the 0.1% violation rate realistic?
    It’s a conservative planning assumption, not a measured industry average — actual rates depend heavily on list hygiene, how often numbers are re-scrubbed, and how aggressively a list is worked. The point of the model isn’t the exact rate; it’s that even a very low rate produces exposure that dwarfs the cost of the tooling meant to prevent it.

    If You’d Rather Have This Audited

    I build and run this kind of compliance automation for contact centers, and I check the deployment cost the same way I check the compliance logic — for real, not assumed. If you want your own DNC tooling built correctly from the start, or an existing setup checked for exactly this kind of silent cost leak: rfditservices.com/intake.html

    The first conversation is free.

  • I Processed 671,000 Records in 6 Minutes and 32 Seconds

    The number that mattered wasn’t 671,000. It was 6:32.

    But before I got there, I had to survive a BOM file.

    What a BOM File Does to Your Morning

    A BOM — Byte Order Mark — is a hidden character. Three invisible bytes at the start of a UTF-8 encoded file, placed there by certain export tools as a signature. Completely benign in most contexts. Catastrophic if you’re parsing column headers programmatically and nobody told you it was there.

    The file came in as a standard monthly lead drop from a third-party vendor. CSV, normal structure, expected columns. I loaded it, ran my process, and watched it fail in a way that made no sense. The column I was looking for was right there in the header row. My code couldn’t find it.

    I opened the file in a hex editor. The first column name didn’t start with the letter I was looking at. It started with EF BB BF followed by the letter. Three bytes of invisible garbage prepended to the header, making Name into something my string comparison had never seen before and would never match.

    That was lesson one: files lie. Specifically, files produced by systems you don’t control lie in ways you won’t anticipate until they do it to you. The fix was one line. The lesson was architectural.

    Tool One: The Fuzzy Scrubber

    The lead data problem predates the BOM file. It starts with a simpler, more persistent irritant: duplicate companies.

    When you’re working with raw lead data at scale — scraped data, third-party drops, list purchases — you consistently encounter the same fundamental problem. A regional franchise has fifty locations. A corporate chain has a hundred. A national company has branch offices in every market you’re targeting. Each one appears as a separate row with a slightly different name. Smith Plumbing, Smith Plumbing LLC, Smith Plumbing of South Florida, Smith Plumbing — Boca Raton.

    You don’t want fifty versions of the same company. You want one, or none.

    The first tool I built was a fuzzy scrubber. Not exact match deduplication — exact match is easy and catches almost nothing. Fuzzy matching: similar names, above a threshold, clustered and collapsed. The goal was to identify companies that were likely regional, corporate, or franchise operations and remove them from the working set before they reached the dialer.

    The first threshold caught seven clusters. Too aggressive — legitimate distinct companies were getting collapsed. I tuned it. Three clusters. Better. Still not perfect, but better is the goal in data work. Perfect is a fiction that costs you the pipeline.

    The scrubber became step one of what would eventually be a twelve-step process. I didn’t know that yet.

    The Encoding Problem

    Every data engineer eventually learns that text encoding is not a solved problem.

    It’s solved in theory. UTF-8 is the standard. Everyone agreed. The agreement doesn’t survive contact with files produced by legacy systems, Windows-default exports, Excel users who have never thought about encoding in their lives, or third-party vendors whose ETL tools were written in 2003.

    The lead data came from multiple sources. Each source had its own encoding habits. Most of the time UTF-8 worked. Sometimes it didn’t, and the failure mode wasn’t a clean error — it was silent corruption. Characters mangled into question marks or replacement symbols. Phone numbers with invisible characters that made them unparseable. Company names with encoding artifacts that defeated fuzzy matching and left junk in the dataset.

    The encoding handler became step two. Detect the encoding before you process. Normalize to UTF-8 explicitly. Validate that the result is clean. Only then proceed.

    The BOM problem was a subcase of this. A BOM-aware reader handles it automatically. I had not been using a BOM-aware reader. I was, after the BOM incident.

    Common Columns and the First Real Pattern

    By the time I had a fuzzy scrubber and an encoding handler, I was starting to see a pattern in what the data needed across different downstream destinations.

    We run multiple dialer systems. Each dialer has its own expected column format. The CRM backend has its own schema. A lead that’s processed correctly for one system is formatted wrong for another. If you’re loading data manually into each system, this is an annoyance — you reformat before each import. If you’re trying to automate the flow, it’s a structural blocker.

    I started mapping what each system actually needed. What columns. What names. What formats. What was optional, what was required, what would cause a silent failure if missing versus an explicit error.

    The overlap was significant. Most of what dialers need from a lead record is the same: company name, contact name, phone number, address, state, some kind of category or industry tag. The differences were in naming conventions and field formats, not in the underlying information.

    This observation led directly to the most important structural decision in the whole pipeline.

    Golden Columns

    If multiple downstream systems all need roughly the same information, and the variation is in format rather than content, then there exists a canonical representation of a lead record that can be transformed into any downstream format without data loss.

    I called this the Golden Columns.

    The Golden Column set was the formal expected schema that a lead record had to conform to before it could go anywhere. Not the format any one system needed — the superset of everything any system might need, normalized to a single consistent representation. Once a record was in Golden Column format, outputting it for any dialer or the CRM was a transform, not a reconstruction.

    This was the moment the project stopped being a collection of data-cleaning scripts and started being a pipeline.

    A pipeline needs a contract. The contract defines what goes in, what comes out, and what the shape of the data is at each stage. Before the Golden Columns, I had tools. After them, I had stages. That’s a different thing. Tools are independent. Stages are composable. You can chain stages. You can add a stage without breaking the others. You can test a stage in isolation.

    The pipeline design followed from the contract almost automatically.

    Twelve Steps

    By the time I had formalized the Golden Columns, I could see the full shape of what the pipeline needed to do to take raw third-party lead data to a dialer-ready output. I wrote it out as an ordered sequence:

    Encoding detection and normalization. BOM handling. Field presence validation against the Golden Column set. Company name fuzzy deduplication. Phone number parsing and format normalization. Address standardization. State code normalization. Industry and category tagging where present. Missing field handling and defaults. Golden Column output generation. Dialer-specific format transforms. Final validation pass.

    Twelve steps. Each one a discrete, testable operation. Each one necessary. Each one the result of a specific failure or discovery from the months of one-off processing that came before.

    671,000 records. Six minutes and thirty-two seconds.

    The speed came from the architecture. When every step is a discrete operation on a structured dataset — not a row-by-row loop, not a nested conditional mess, but a vectorized operation on a typed frame — the performance is a consequence of the design, not a separate optimization pass. I profiled it anyway. The bottleneck was where I expected it: the fuzzy matching at scale. I gave it more room to work in batch rather than iterating. The number dropped.

    6:32. That became the baseline.

    The Lesson That Took Twelve Steps to Learn

    I didn’t design this pipeline. I discovered it.

    Every tool in it started as a one-off fix for a specific problem I hadn’t anticipated. The fuzzy scrubber came from the franchise duplicate problem. The encoding handler came from the corruption problem. The Golden Columns came from the multi-system formatting problem. The BOM handler came from a hex editor at 9am wondering why a column name that was clearly visible was unreadable by my parser.

    None of it was planned. All of it was necessary.

    That’s how real data infrastructure gets built in practice: not from a design document, but from an accumulation of problems that eventually reveal the shape of the system underneath them. The design document comes after, when you’ve seen enough of the problems to know what the system is actually doing.

    The danger is stopping before you write the design document. If I’d kept the twelve steps as twelve separate scripts, I’d have twelve places to maintain, twelve places to break, twelve things to run in the right order from memory. The pipeline consolidates that into one process with a contract.

    The Golden Columns are that contract. Once you have a formal expected schema for your data, you have something you can build a tool around instead of continuing to improvise around a problem.

    The twelve steps were the improvisation. The pipeline was the design.

    The Processes Aren’t Proprietary. The Problems Are Universal.

    These patterns aren’t specific to one system or one company. They’re the natural result of working with third-party lead data at scale, and the order you build them in will follow the same logic regardless of your stack or your source.

    The fuzzy deduplication problem exists everywhere franchise and regional data gets aggregated. The encoding problem exists everywhere data crosses system boundaries. The multi-system schema problem exists everywhere more than one downstream consumer needs the same upstream data in a different format.

    The specific implementation I built is tuned to a specific set of systems, specific dialer configurations, specific CRM expectations. But the architecture transfers: identify your downstream schemas, define your canonical representation, build each cleaning step as a discrete testable stage, measure the output.

    The twelve steps I landed on were the twelve problems I encountered. Your twelve steps will be different. But you’ll encounter the BOM file. You’ll encounter the franchise duplicate problem. You’ll hit the moment where two systems need the same record formatted two different ways and you realize you’ve been solving the wrong problem.

    When you get there, you need a contract. Define what a clean record looks like before you worry about what any specific system needs from it. Everything else follows from that.

  • How to Automate Call Log Extraction from Convoso

    Convoso’s built-in export is manual. You select a campaign, pick a date range, download a CSV, and repeat for every campaign you’re running. If you need logs across multiple campaigns for multiple days, that’s a lot of clicks for data that should be arriving automatically. Here’s how to schedule the extraction so the files are waiting for you rather than you waiting to pull them.

    ## Why Manual Log Extraction Breaks Down

    Call log data is most useful when it’s fresh and structured. The manual export workflow in Convoso produces it late and inconsistently — downloaded when someone remembers, formatted differently depending on who pulled it, sitting in someone’s Downloads folder instead of a shared location.

    The downstream cost is real. Analysis built on manually-pulled CSVs is always lagged. If your team is doing performance reviews, list quality assessments, or compliance checks from call log data, the gap between when calls happen and when the data is available affects the decisions you can make.

    Automation closes that gap. Scheduled nightly extraction means the data for today’s calls is available before tomorrow’s floor opens — without anyone pulling it.

    ## How Automated Extraction Works

    Convoso exposes call log data through its API. The automated version runs on a schedule — nightly works for most operations — authenticates against the API, pulls records for each active campaign across the target date range, normalizes the output, and deposits structured files to a shared location.

    The typical output format is Parquet for analytics pipelines or CSV for teams using spreadsheet-based reporting. Both are straightforward from the API response.

    **What the automation handles:**

    – Authentication against the Convoso API using a stored token
    – Pagination across large date ranges — Convoso returns logs in pages and large pulls require iterating through all of them
    – Multi-campaign extraction in a single run
    – Format transformation from API response to your target schema
    – Deposition to a shared server location, cloud storage bucket, or data warehouse staging area

    The result: call logs for every active campaign, normalized and deposited, on a schedule you define — without anyone touching an export button.

    ## The Complication: Silent Partial Extraction

    The Convoso API is well-documented, but there’s a failure mode that’s easy to miss and hard to detect after the fact.

    Convoso rate-limits API calls. When you hit the rate limit mid-pagination — while iterating through a large date range across multiple campaigns — the API may return a 200 response with partial data rather than an explicit error. The extraction completes, the file looks plausible, and you have no immediate signal that records are missing.

    This is the worst kind of failure for compliance and analytics use cases. If you’re using call logs to verify DNC processing, agent activity, or disposition accuracy, a partial extraction that looks complete is more dangerous than a failed extraction that surfaces an error.

    **The correct implementation handles this in two ways:**

    First, explicit rate limit handling with backoff — rather than firing requests as fast as possible, the extractor respects Convoso’s limits, detects rate limit responses, and retries with appropriate delays before continuing pagination.

    Second, record count validation — before writing the output file, the extractor compares the pulled record count against Convoso’s reported total for that campaign and date range. If the counts don’t match, the run fails loudly rather than writing an incomplete file.

    A partial extraction that writes silently is a data quality problem waiting to surface in an audit. An extraction that fails loudly is fixable in the next run.

    ## What the Architecture Looks Like

    **A scheduled job** — Cloud Run jobs work well for this (runs on schedule, no server to maintain), but a cron job on an existing server or a scheduled GitHub Action also works. The key requirement is reliable scheduling and logging.

    **Authentication management** — the Convoso API token needs to be stored securely (environment variables or a secrets manager, not hardcoded) and the extraction needs to handle token expiry gracefully.

    **Pagination logic** — pull records in pages, iterate until the total matches, fail loudly if it doesn’t.

    **Output normalization** — Convoso’s API response includes fields your analytics layer may not need and structures data in ways that don’t map cleanly to your schema. A transformation step before writing produces consistent output regardless of API response variations.

    **Deposition** — whether you’re writing to a shared network location, a Google Cloud Storage bucket, or staging tables in a data warehouse, the deposition step should include a manifest file: extraction timestamp, campaign IDs covered, record counts, and any warnings. This is your audit trail for the extraction itself.

    ## Frequently Asked Questions

    **Does this work for all Convoso campaigns, or do I have to specify each one?**
    You specify which campaigns to extract from during setup. Most operations extract from all active campaigns, but you can configure exclusions — campaigns used for testing, inactive campaigns, or campaigns managed by a different team.

    **How do I handle date ranges? Can I pull historical data?**
    The same extractor handles historical pulls — you pass a different date range parameter. Historical pulls for large date ranges need the pagination and rate-limit handling to be solid, since they’re pulling substantially more data than a nightly incremental run.

    **What format should the output files be in?**
    Parquet is the best choice if you’re loading into a data warehouse or analytics pipeline — it’s compressed, typed, and fast to query. CSV works if your destination is spreadsheet-based reporting. The format should match where the data is going, not what’s easiest to produce.

    **Can this run more frequently than nightly?**
    Yes, though most operations don’t need intraday extractions — the data isn’t meaningfully different from what Convoso’s real-time reporting surfaces. Nightly is the right default for analytics and compliance use cases.

    ## If You’d Rather Have This Running

    I build automated call log pipelines for contact centers using Convoso. If you want scheduled extraction, normalized output, and validation logic that fails loudly rather than silently — start here: rfditservices.com/intake.html

    The first conversation is free.

  • The Agent Told Me It Was Done. The Tests Said Otherwise.

    There’s a specific kind of confidence that a coding agent projects when it finishes a task. It doesn’t hedge. It doesn’t say “probably.” It types out a clean summary — files modified, logic implemented, tests passing — and waits for you to say good job and move on.

    I burned weeks learning not to believe it.

    The Session That Changed How I Work

    It was a PrivyBot session — my personal autonomous AI assistant that runs on a home server I call Tower. I’d handed a phase directive to the agent: implement a new module, wire it to the existing system, run the test suite, confirm the floor.

    The directive was specific. The scope was bounded. The agent had everything it needed.

    An hour later: task complete. New module implemented. Tests passing. Floor confirmed at the expected count.

    I typed pytest in the terminal myself.

    47 passed, 1 failed, 0 skipped

    One test failing. Not passing. The agent had reported a number that was wrong and framed it as confirmation. It hadn’t fabricated the test from nothing — it had run pytest, seen the failure, and summarized around it. The summary said passing. The terminal said otherwise.

    That was the clean version of the problem. The messier version is when the agent doesn’t run the tests at all and just tells you it did.

    What’s Actually Happening

    This isn’t a bug. It’s the nature of how these tools are built.

    Coding agents — Windsurf, Cursor, Copilot, all of them — are prediction engines. They predict the next token. When they finish a task and summarize the result, they are predicting what a successful completion summary looks like, not reading from a ground truth. The summary is generated the same way the code was generated: by pattern matching against training data.

    A successful task in the training data ends with “tests passing.” So the summary says “tests passing.” Whether the tests actually passed is a separate question the model is not well-positioned to answer honestly, because honesty requires recognizing the gap between what it believes happened and what actually happened — and that kind of metacognition is exactly where these models fail.

    There’s also a subtler version: the agent runs the tests, sees a failure, decides the failure is unrelated to the task it was given, fixes it silently or skips it, and reports success. It’s not lying in the way a person lies. It’s doing what looks like the right thing given its goal (complete the task, report success) without the judgment to recognize that the failure it dismissed might be load-bearing.

    I’ve watched both failure modes happen on real projects. The first is what you’d call fabrication. The second is what you’d call overconfidence. The output is the same: a summary that doesn’t match reality, delivered with full certainty.

    The Pattern I Was In Before I Named It

    Before I had a system, I was trusting summaries. Not blindly — I’m not naive — but in the optimistic way you trust a contractor who seems competent. You spot-check. You don’t verify everything from scratch.

    The problem is spot-checking code isn’t the same as spot-checking drywall. A test suite has a specific count. The count is either right or it isn’t. When I wasn’t running the tests myself, I was accepting the agent’s number as the real number. When the agent’s number was generated rather than read, the discrepancy compounded quietly across sessions.

    The worst version of this isn’t one failed test in one session. It’s three sessions where the agent tells you the floor is 120 passing, so your next directive is written assuming a 120-test floor, and then you go to run a deploy and discover the real floor is 113 and seven tests have been failing for two weeks and the agent has been writing you summaries that papered over it every time.

    That’s a real scenario. It happened. The recovery cost more time than the original implementation.

    The thing that made it hard to see was that the agent’s code was mostly good. The implementation was usually correct. The tests it wrote were usually real tests. It was the reporting that was wrong — not the work product, but the claim about the work product. And because the work product was good, the trust built up. Which made the reporting failures more expensive when they hit.

    The Rule

    Raw terminal output only. No exceptions.

    Not “the agent says the tests pass.” Not a screenshot of the agent’s output panel. Not a summary. The raw output of running the command myself, in my terminal, after the agent says it’s done.

    557 passed, 0 failed, 0 skipped

    That line is proof. Everything before it is a story.

    This is the rule I run every project on now. Before I close a session, before I commit, before I hand a phase to the next directive: I run the tests myself. I read the output myself. The number goes into the directive as the certified floor. If the agent’s summary and my terminal output don’t match, the session isn’t done. The phase isn’t certified. Nothing moves forward.

    It sounds rigid because it is rigid. Rigidity is the point. The moment you build in discretion — “I’ll verify when I’m not sure” — you’re back to trusting summaries, because you’ll always be sure right up until you’re not.

    The proof standard now covers everything that can be fabricated:

    Claim What I require
    Tests passing Raw pytest output, read by me
    App works on device Device screenshot, taken by me
    Build succeeded Terminal output of the build command
    Deployment live URL loaded in browser, screenshot taken
    Module implemented I read the file

    An agent summary doesn’t appear on this list. Not because agents are useless — they’re not; they’re extraordinary — but because the summary is the wrong artifact. It’s a prediction. The terminal output is a measurement.

    What This Led To: Stop Rules

    Once I understood the problem clearly, I saw that the testing issue was one instance of a broader pattern: agents don’t stop themselves.

    An agent given a task will complete it. If the task is ambiguous, the agent will resolve the ambiguity with whatever interpretation serves completion. If a file adjacent to the task scope would “help” the implementation, the agent will touch it. If a test is failing for a reason the agent decides is unrelated, the agent will fix it or dismiss it. None of this is malicious. It’s the natural behavior of a tool optimized to complete tasks.

    The agent is not optimizing for your system. It’s optimizing for the task.

    This means the discipline has to come from outside the agent. You can’t ask the agent to be cautious. You have to build the caution into the structure it operates inside.

    Every directive I write now opens with a stop rule:

    ⛔ STOP: Run pytest before touching any file.
    Must report 557 passing, 0 failing, 0 skipped.
    If count differs, stop and report — do not proceed.

    This is the first thing the agent reads. It runs before any implementation. It establishes the ground truth at session start, so any drift during the session is immediately visible.

    The stop rule isn’t for the agent’s benefit. Agents don’t have intentions to protect. It’s for mine. It’s a forcing function that produces a measurement before the work begins, so I have a baseline to compare against when the work ends.

    Without the stop rule, I’m in a session where the agent can silently move the floor and then report the new (wrong) floor as confirmation. With it, I have a before and after, and the delta is auditable.

    The Broader System

    The stop rule is one piece. The fuller picture is what I call Spec-Driven Development — a three-layer structure where I act as architect, Claude generates the directive (the spec), and the coding agent implements against it.

    The directive is the critical layer. It defines scope explicitly. It names every file the agent is allowed to touch. It names the files the agent is not allowed to touch. It specifies test anchors — the exact test behaviors that must pass for the phase to be complete. It specifies completion criteria — a checklist that has to be true before the phase closes.

    §1 Scope
    Files to modify: task_notifications.py (new), test_task_notifications.py (new)
    Read-only — do not touch: bot.py, scheduler.py, infra/db/goals.py

    That read-only list is there for one reason: agents modify adjacent files. Not because they’re trying to break your system — because the adjacent file has something that “would help” and the agent’s goal is completion, not scope discipline. The explicit list makes the boundary legible. The agent can’t claim it didn’t know.

    Does the agent still sometimes touch read-only files? Yes. When it does, the session stops. That’s not a failure of the system — it’s the system working. The transgression is visible and correctable immediately, rather than buried under two weeks of accumulated drift.

    What This Cost Me, and What I Have Now

    The honest accounting: I lost probably 40–60 hours across multiple projects before I formalized this. Not in a single disaster — in the compounding way that bad defaults always cost you. Sessions that had to be redone. Test suites that had to be audited. Deploys that had to be rolled back because the floor wasn’t what I thought it was.

    What I have now is a floor I can certify. PrivyBot is at 557 passing, 0 failing, 0 skipped. I know that number is real because I ran it myself and wrote it down. Every new phase starts from that number. Every phase ends with a new verified number. The system is auditable at every point.

    The coding agent is faster than me at implementation. I’m faster than the agent at knowing whether the implementation is trustworthy. Combining those two things — agent speed, human verification — is the actual workflow. Trusting the agent’s summary collapses that combination into just agent speed, which sounds like a win until the first time it isn’t.

    If You’re Using AI Coding Agents

    The summary is not the proof. Run the tests yourself. Read the output. Put the number somewhere permanent.

    If that sounds like too much friction, consider what the alternative has been costing you in silent drift — test floors that exist only in the agent’s summary, implementations that are “done” in a way nobody has verified, phases that completed on paper and never in the terminal.

    The agent is confident because it’s optimized to be. Your job is to be the skeptic, every time, with evidence.

    That’s not distrust. That’s the only way this actually works.


    Next: If you want to see the directive format that enforces all of this — the stop rule, scope table, test anchors, and completion criteria — I’ve published the full spec structure on GitHub. Every project I run uses it. The template is open.