Author: rdugger

  • How to Automate Lead List Import in Convoso

    Convoso’s manual list import is a CSV upload through the interface — one file at a time, one campaign at a time. If your operation receives new lead files regularly from vendors or internal sources, that manual process is a recurring bottleneck that automation eliminates. Here’s how to build scheduled, automated list import with the hygiene checks that protect every campaign before the first dial goes out.

    ## The Manual Import Problem

    Manual list import in Convoso has two failure modes.

    The first is operational: someone has to do it. New leads sitting in a shared drive because the person who handles imports is busy, out, or forgot is a dialing opportunity that’s been missed. At the start of a day, a campaign that needed fresh inventory isn’t getting it because the list is still unimported.

    The second is compliance: manual imports rarely include pre-import hygiene. A CSV arrives from a vendor, someone uploads it to Convoso, and the campaign starts dialing. The list hasn’t been checked against the Federal DNC registry, state DNC lists, the litigator database, or your own internal opt-outs. The compliance exposure starts with the first dial.

    Automated import handles both. Lists arrive, get processed, get scrubbed, and land in Convoso ready to dial — on schedule, with documentation.

    ## How Automated Import Works

    The import pipeline runs on a schedule. When a new list file arrives at a defined location — a cloud storage bucket, a shared network folder, or a vendor SFTP drop — the pipeline picks it up, processes it, and imports it into the correct Convoso campaign.

    **The pipeline stages:**

    **1. Intake.** Monitor the source location for new files. When a file appears, validate it — expected columns, no encoding issues, minimum record count. A malformed file should fail loudly before reaching any subsequent stage.

    **2. Pre-import hygiene.** Scrub the list before it touches Convoso:
    – Deduplicate against leads already active in the target campaign
    – Check against your internal opt-out list
    – Check against the Federal DNC registry
    – Check against state DNC lists for relevant states
    – Check against the litigator database (Blacklist Alliance, Contact Center Compliance, or equivalent)

    Records that hit any of these checks are removed from the import set and logged with the reason. What remains is a clean list.

    **3. Import.** Use the Convoso API to create or append to a list in the target campaign. The API supports programmatic list management — you’re not screen-scraping the upload interface.

    **4. Post-import confirmation.** Verify the record count in Convoso matches the expected post-hygiene count. If the counts don’t match, the pipeline fails loudly rather than assuming the import completed correctly.

    **5. Logging.** Record the file name, source, timestamp, pre-hygiene count, post-hygiene count, removals by reason, and the Convoso list ID the records landed in.

    ## The Complication: Compliance Exposure Starts at First Dial

    The sequence matters. The hygiene checks have to happen before import, not after.

    A list imported to Convoso without pre-scrubbing starts accumulating compliance exposure from the moment the first dial goes out. If a litigator’s number is on the list, that dial is already a liability — it can’t be undone retroactively by a DNC removal after the fact. The call happened. TCPA violations attach to completed calls, not pending ones.

    The trap most operations fall into: they have a DNC removal process for numbers that come in after the campaign is running, but no systematic pre-import scrubbing. The removal process handles complaints; it doesn’t prevent them.

    **The correct sequence:**

    1. Receive the list
    2. Run all hygiene checks
    3. Remove flagged records and log the reason
    4. Import only the clean remainder
    5. Document the hygiene run with timestamps

    This is prevention, not remediation. The list that enters Convoso should already be clean — the hygiene run is the proof.

    **The litigator list specifically:** most automated import pipelines check Federal DNC and stop there. The litigator database is a separate scrub against numbers held by serial plaintiffs. These numbers aren’t on any opt-out list. They’re clean by standard DNC criteria and still represent the highest TCPA litigation risk in your list. Pre-import scrubbing against Blacklist Alliance, Contact Center Compliance’s Litigator Scrub, or an equivalent is the check most operations miss.

    ## What the Architecture Requires

    **A file watcher or scheduler.** The trigger for import automation depends on how lists arrive. If vendors drop files to a cloud storage bucket, a storage trigger can initiate the pipeline when a new file appears. If files arrive on a schedule, a cron job or scheduled Cloud Run job works. If files come from an SFTP server, a polling job checks the server on a defined interval.

    **A normalization layer.** Vendor lists arrive in different formats, column orders, and encodings. A normalization step before hygiene checking converts every input to a consistent internal format — E.164 phone numbers, standardized column names, UTF-8 encoding.

    **Hygiene API integrations.** Each hygiene check requires an API call to the relevant service. The pipeline needs valid credentials for each service and should handle API failures gracefully — a hygiene service that’s temporarily unavailable should pause the import, not skip the check.

    **Convoso API integration.** List creation and record upload use the Convoso API. The integration needs valid credentials and should validate the upload response against the expected record count.

    **Logging to durable storage.** Every import run should produce a log entry that’s retained. If a compliance question arises months later about a specific number, the log should show when it was imported, which hygiene checks it passed, and which campaign it landed in.

    ## Frequently Asked Questions

    **Does Convoso support programmatic list management through the API?**
    Yes. The Convoso API includes endpoints for list creation, record addition, and campaign assignment. The interface upload is the most visible path, but the API supports the same operations at scale without manual steps.

    **What if a vendor sends the same number on multiple lists?**
    The deduplication stage catches this — numbers already present in the target campaign are excluded from the new import with the reason logged. If the same number appears multiple times in a single incoming file, that’s caught during normalization before any API calls are made.

    **How does the pipeline handle files from multiple vendors with different formats?**
    Each vendor source gets a normalization configuration — a mapping from that vendor’s column names and format to the internal standard. New vendor sources require a new normalization config, not changes to the core pipeline logic.

    **What happens if the Convoso API import partially succeeds?**
    The post-import record count check catches this. If Convoso confirms fewer records than the pipeline sent, the run is flagged as incomplete rather than successful. The remaining records can be retried without re-running hygiene on the already-imported subset.

    ## If You’d Rather Have This Running

    I build automated list import pipelines for contact centers using Convoso — with the pre-import hygiene, the record count validation, and the audit logging that makes every import defensible. Start here: rfditservices.com/intake.html

    The first conversation is free.

  • The Rule Said 0.25%. The Math Said It Was Actually Enforcing 0.056%.

    There was a rule that had been running for a long time: if a list’s contact rate drops below a fixed number after enough attempts, cut it and move to something else. Simple, defensible-sounding, the kind of rule nobody questions because it’s been there since before anyone currently on the team arrived.

    I sat down to actually check what that rule was defensible *against* — not what it claimed, what it proved. A flat cutoff at a fixed percentage doesn’t account for how much you can trust an observation at a given sample size. Cut a list at 400 attempts and 0.25%, and the honest, statistically rigorous floor that observation actually clears — accounting for the real uncertainty at that sample size — turns out to be 0.056%. Not 0.25%. Seven times lower. The rule’s name promised one standard and delivered a much more trigger-happy one, and nobody could see the gap because nobody had run the number.

    That explained something that had been bothering people for longer than the rule itself had existed: cuts kept getting reversed. A list would get pulled for underperforming, and later turn out to have been fine. I went and checked, against the real history, how often that happened — pulled every moment the rule would have fired across a quarter of real data, then checked what those same lists did in the following day. About one in six to one in four of them recovered on their own within 24 hours. Not because the rule was wrong to exist. Because it had never been calibrated to know the difference between “actually bad” and “noisy this hour.”

    While I was in that part of the system, I went looking for the audit trail — the log of every automated decision the balancer had ever made, expecting to be able to reconstruct exactly which lists got swapped for what reason. The log existed. It had the right columns for it — which list got removed, what replaced it. Every single row had those columns empty. Every reason field said the same generic string, verbatim, on all thirty-four thousand rows. The audit trail had been built and never actually wired up to record anything real.

    The struggle in both of these wasn’t technical — it was resisting the instinct to fix the surface symptom (adjust the cutoff number) instead of asking whether the whole shape of the rule was the problem. A better number on a badly-shaped rule is still a badly-shaped rule.

    The lesson: when a threshold has a specific number in its name, ask what that number is actually defensible against, not what it claims to be. The two are not always the same thing, and the gap between them is where false confidence lives.

    Next: replacing the flat cutoff with a rule that adjusts automatically for how much history a list has behind it, instead of hand-picking a new number every time the old one stops working.

  • Weekend Warrior Mode

    It’s 8PM on a Saturday. My daughter is asleep. The Tower is running at my parents’ place — a Dell OptiPlex 5050 I’ve got humming in their corner for $40 a month. I open the laptop and I know exactly how long I have.

    That’s the moment. Not inspiration. Just a window.

    The surprise came the next morning. I opened Telegram and something had already happened. RALPH — the autonomous loop I’d built into PrivyBot — had fired overnight. Ran research on Rust ECS patterns. Connected it to VoidDrift. Created its own Google Tasks for follow-up. Left results waiting for me before I’d had coffee.

    I hadn’t asked it to do anything. It just worked while I slept.

    The struggle isn’t the code. It never is. It’s the continuity tax — picking up a session three days later and spending the first hour remembering where you were. It’s the day job that starts Monday whether or not Saturday’s session felt finished. It’s being a parent and a developer and someone who wants to build something real, all at the same time, in windows that close whether you’re done or not.

    What I’ve learned: the constraint is the forcing function. When you have until 2AM, you scope tightly. You don’t gold-plate. You ship the thing that moves the needle and close the laptop. Weekend Warrior Mode isn’t a limitation — it’s a discipline that full-time builders don’t have to develop.

    The system works while I sleep now. That’s the whole point. Not to replace the sessions — to make the sessions compound.

  • How to Integrate Convoso with Zoom Contact Center

    Running Convoso and Zoom Contact Center in the same operation means compliance actions — DNC removals, blocklist updates, number suppression — need to reach both systems. Here’s how to build a unified integration layer so a single operation hits every platform simultaneously, with confirmation from each.

    ## The Multi-Platform Problem

    Most contact centers that run Convoso alongside Zoom Contact Center manage them as separate systems. A DNC removal request goes to whoever is logged into Convoso. Someone else handles the Zoom side. If the request comes in during a busy period, one system gets updated before the other. The number stays active somewhere while the team catches up.

    That gap is the liability. TCPA compliance doesn’t distinguish between systems — if a number reaches an agent through any platform, the removal failure is yours regardless of which system caused it.

    A unified integration closes the gap by treating every compliance action as a single operation across all active platforms, completed simultaneously, confirmed from each.

    ## How the Integration Works

    The architecture uses Slack as the intake point — a slash command accepts the number from an ops manager and routes it to a cloud service that handles the fan-out.

    The fan-out layer calls both the Convoso API and the Zoom Contact Center API in parallel. Not sequentially — simultaneously. Each system processes the operation independently. The integration waits for responses from all systems before returning a confirmation to Slack.

    **What the confirmation shows:**
    – Which systems received and confirmed the operation
    – Which systems returned errors, with the specific error
    – Timestamp of the operation for audit purposes

    A manager submitting a DNC request sees, in the same Slack thread, confirmation from every system — or a clear failure indicator for any system that didn’t confirm.

    ## Convoso API Integration

    Convoso exposes its DNC and campaign management through a REST API. Authentication uses an API token passed as a header. The relevant endpoints for number suppression are documented in Convoso’s API reference and cover both campaign-level exclusions and account-level DNC additions.

    The integration authenticates once per service startup, validates the token is active, and uses it for all subsequent requests. Token expiry is handled with a credential refresh cycle — a token that expires mid-operation should return an explicit authentication error, not a silent failure.

    For multi-campaign operations — when the same number needs to be removed across several active campaigns — the integration iterates through each campaign ID and confirms removal from each. The response to Slack includes the campaign-level breakdown, not just an aggregate.

    ## Zoom Contact Center API Integration

    Zoom Contact Center has a separate authentication flow from Convoso — OAuth2 rather than API token. The integration maintains its own Zoom credential lifecycle, refreshing the access token before it expires rather than on-demand.

    The Zoom Contact Center API exposes blocklist and DNC management endpoints. Number formatting matters — Zoom expects E.164 format while Convoso is more permissive. The integration normalizes the input number to E.164 before sending to Zoom, regardless of how it was entered in the slash command.

    ## The Complication: Partial Failure Is the Dangerous Case

    A simple fan-out that returns “success” or “failure” based on whether all systems confirmed is insufficient for a compliance tool.

    The dangerous case is partial failure: Convoso confirms the removal, Zoom returns an authentication error. The operation looks like it failed — but it half-succeeded. The number is removed from Convoso and still active in Zoom. If the confirmation to Slack just says “failed,” the ops manager may try again, causing a duplicate removal in Convoso while Zoom still hasn’t been updated.

    **The correct behavior distinguishes three states per system:**

    – Confirmed: the system returned a success response
    – Failed: the system returned an error response with a specific error code
    – Unreachable: the system didn’t respond within the timeout window

    The Slack confirmation shows each system’s state independently. A partial failure prompts the operator to retry the specific failed system — not the entire operation.

    This also matters for the audit trail. The log entry for every operation should capture each system’s response individually, not just an aggregate outcome. When a compliance question arises about a specific number, the log should show exactly which systems processed the removal and when — not just whether the automation ran.

    ## What the Architecture Requires

    **A Slack slash command** configured for the specific DNC workflow — accepting a phone number, optionally a reason code, and routing to the backend service.

    **A backend service** with separate authenticated sessions for each platform. Convoso token management and Zoom OAuth2 management are independent — a Convoso credential issue shouldn’t prevent the Zoom operation from proceeding and vice versa.

    **Parallel execution** for the fan-out. Sequential execution means the slowest system determines the total response time. Parallel execution means all systems are contacted simultaneously and the integration waits for all responses before returning.

    **Per-system logging** to a durable store — cloud storage or a database — with the operation timestamp, the number, and each system’s individual response. This is your audit documentation.

    **Timeout handling** so a non-responsive system doesn’t block the confirmation indefinitely. A system that doesn’t respond within a defined window gets logged as unreachable, the other systems proceed, and the operator is notified to follow up manually.

    ## Frequently Asked Questions

    **Does this require separate API credentials for each platform?**
    Yes. Convoso and Zoom Contact Center use different authentication models with separate credentials. Both need to be provisioned, stored securely, and managed independently.

    **What happens when Zoom’s access token expires mid-operation?**
    The integration should handle OAuth2 token refresh proactively — refreshing before expiry rather than on 401 responses. A mid-operation token expiry should trigger a single refresh attempt before failing the Zoom leg of the operation.

    **Can this be extended to other platforms?**
    Yes — the fan-out architecture is extensible. Adding a new platform means adding its authentication logic and API calls to the fan-out layer. The Slack confirmation and audit logging handle any number of platforms without structural changes.

    **How do we handle number format differences between platforms?**
    Normalize to E.164 at the intake layer before the fan-out. One normalization function applied to the input number guarantees each platform receives it in the format it expects.

    ## If You’d Rather Have This Built

    I build multi-platform compliance automation for contact centers running Convoso and Zoom Contact Center. If you want the unified fan-out, the per-system confirmation, and the audit trail set up correctly — start here: rfditservices.com/intake.html

    The first conversation is free.

  • The Number Was Wrong by 2x, and I Found It by Predicting the Wrong Number in Advance

    The forecast had been “very inflated” for weeks. Nobody could say by how much, or why — just that the projected end-of-day numbers didn’t match what actually happened, often enough that people had started mentally discounting them.

    I went looking for the model first, because that’s where you look. What I found instead was that the fifteen-minute data feeding the forecast was cumulative — a running total for the day, not a fresh count per interval. I confirmed it the boring way: pulled the raw rows and watched them climb, strictly, all day — never dropping, only ever adding on top of the last number. That’s the signature of a running total, not a series of separate readings.

    The actual bug wasn’t in that data. It was one step downstream, in the code that consumed it. Somewhere in the pipeline, someone had written a loop that summed those cumulative numbers as if they were fresh increments — adding a running total to another running total to another, compounding a small mistake into a large one. In one function I found the two clearest evidence of it happening live: one number in the loop correctly took the maximum value across the period, and the number right next to it — same loop, same author, same line count away — used addition instead. One field right. One wrong. Nobody had noticed, because both numbers looked plausible in isolation.

    Before I told anyone what I’d found, I wrote down what the bug should produce if I was right — a specific projected number, checkable against the live dashboard within the hour. I did the math, then went and looked. The dashboard read almost exactly what I’d predicted it would if the bug was real: roughly double the actual count, growing toward quadruple by late afternoon as more cumulative snapshots piled onto the sum.

    The struggle wasn’t finding the bug. It was resisting the urge to declare victory the moment I found *a* plausible cause, instead of confirming it actually explained the whole shape of the problem — the way it got worse through the day, not just that it was wrong. A bug that only explains part of a symptom isn’t the bug yet.

    The lesson: if you can predict a specific number a bug should produce, and go check it against reality before you tell anyone you found the answer, you’ve turned a guess into a proof. That one habit is the difference between “I think I found it” and “I found it.”

    Next: fixing the aggregation at its actual source, not patching the number that comes out the other end.

  • I Shipped a Game to Android and the Web From the Same Codebase

    The game is called VoidDrift. You mine ore in orbit around a dying star, build a production chain, and feed a black hole that is slowly eating everything. It runs in a browser. It runs on Android. The same code does both.

    I didn’t plan for that to be interesting. It turned out to be the hardest part of the whole project.

    The Decision

    When I started VoidDrift I was building in Rust with Bevy, a game engine that’s relatively young and takes strong opinions about how game logic should be structured. The choice to target both WASM — which runs in a browser — and Android wasn’t a roadmap item. It started as a question: if the game runs on my machine, how much work is it to make it run everywhere?

    The answer turned out to be: more than you’d expect, and less than you’d fear. But the path between those two things involves a specific class of problem that nobody warns you about.

    The browser and Android are not the same target. They have different input models, different screen assumptions, different rendering constraints, different deployment pipelines. Building for one teaches you nothing about building for the other. Building for both at the same time forces you to find the seam where your game logic has made assumptions it shouldn’t have.

    That seam is where VoidDrift got redesigned.

    What Breaks First

    The first thing that breaks is your assumption about screen size.

    I tested on my development machine. Everything looked correct. I deployed to my Android device — a Moto G 2025 — and watched half my UI disappear behind the operating system’s navigation bar. The bar that lives at the bottom of the screen, with the back button and the home button, was sitting on top of my game without telling me.

    The device reports its screen resolution one way. The usable area after the OS takes its share is something different. I had built the entire UI assuming the numbers the device reported were the numbers I’d actually get. They weren’t.

    This sounds like a small problem. It took a full session to diagnose and fix because the failure mode was invisible on every platform except the physical device. The emulator didn’t reproduce it. The browser didn’t have it. Only the real hardware showed the real problem.

    That’s the tax on multi-platform development: the bugs that only exist on one target, found only by running on that target. You can’t test your way around physical hardware.

    What the Browser Does Differently

    The browser version has a different class of problem. WASM — WebAssembly, the format that lets Rust code run in a browser — imposes constraints on how your game loop can work. Things that are straightforward on a native target become negotiable in the browser.

    The biggest one for VoidDrift was the tutorial. The tutorial walks new players through the core loop — mining, forging, building. It works correctly in the native build. In the browser it was broken in a way that was difficult to pin down: state that should have persisted wasn’t, transitions that should have triggered weren’t.

    The fix required understanding how WASM handles the execution context differently from native, and adjusting the tutorial’s state machine to match. The game logic didn’t change. The assumptions the game logic was making about its environment had to.

    This is the pattern with multi-platform work: you don’t change what the game does, you change what the game assumes about where it’s running.

    The Architecture That Made It Possible

    VoidDrift survived the multi-platform problem because of a decision made early: the game logic, the economy logic, and the world logic live in separate modules. They don’t know about each other directly. They communicate through a shared interface.

    When the Android problem surfaced, I fixed it in one place. When the WASM problem surfaced, I fixed it in one place. Neither fix touched the game logic that was working correctly on both targets.

    This sounds like standard software engineering advice, and it is. It’s also advice that’s easy to ignore when you’re building a game because games have a tendency to grow organically — you add the feature where it’s convenient, not where it belongs. The convenience debt compounds until you’re in a situation where fixing a UI bug requires touching three files that have nothing to do with UI.

    The architecture decision wasn’t made because I foresaw the multi-platform problems. It was made because the codebase was getting hard to reason about and I needed it to be legible again. The multi-platform resilience was a side effect of the legibility project.

    That’s usually how it works.

    What Shipping Actually Meant

    VoidDrift is live on itch.io. Browser version loads directly in the page. Android version downloads and installs. Both built from the same repository, deployed with a single script.

    505 views. 239 plays. 10,400 impressions.

    Those aren’t large numbers. They’re real numbers, which is different from the numbers a game has before it ships. Before you ship, the number is zero and you’re making decisions based on what you think will happen. After you ship, the number is whatever it is and you’re making decisions based on what actually happened.

    The browser version gets played more than the Android version. That was surprising. The assumption going in was that mobile would dominate — people play games on their phones. The reality is that someone encountering an idle game in a browser is more likely to click play than someone who has to download and install an APK first. Friction matters. The format that removes friction wins.

    That’s the kind of thing you only learn by shipping to both and watching what happens.

    What I’d Tell Someone Starting This

    Target one platform first. Get it working. Get it shipped. Then add the second target with eyes open to the assumption problem.

    The assumption problem is this: every line of code you write makes an assumption about the environment it will run in. Most of those assumptions are invisible until the environment changes. Changing to a new platform surfaces all of them simultaneously, which is overwhelming. Changing platforms after you already have a working, shipped product means you have a stable baseline to compare against when something breaks.

    VoidDrift on Android broke in ways VoidDrift on WASM didn’t. VoidDrift on WASM broke in ways VoidDrift on Android didn’t. Neither set of breaks was predictable in advance. Both were fixable because the codebase was organized well enough to isolate them.

    Ship the first version. Let the second platform teach you what the first version assumed.

  • Two Forecasting Systems, and Only One of Them Was Real

    The forecast was wrong, and everyone knew it was wrong, and nobody knew why.

    That’s the specific, uncomfortable place to start a debugging session from. Not “there’s a bug” — there’s a number, printed on a dashboard people actually look at, and it’s been quietly too high for weeks. Not broken enough to alarm anyone. Wrong enough that nobody trusted it.

    I went looking for the model. That’s the first mistake, and I want to be honest about it: I assumed there was one forecasting system, and it had a bug in it. What I actually found, once I started reading the code instead of the documentation about the code, was two separate systems living in the same codebase. One was a machine learning model — trained, evaluated, and then never actually saved anywhere. A path that looked live in the architecture diagram and had been dead for who knows how long. The other was a much simpler statistical system, tracking completion ratios against historical patterns, retrained regularly, and — when I actually tested it in isolation — producing numbers that looked correct.

    That was the surprise. The model everyone assumed was doing the forecasting wasn’t running at all. The real system was quietly fine. Which meant the inflated number wasn’t coming from a broken forecast. It was coming from something downstream of a correct one.

    The struggle wasn’t finding the second system — it was sitting with the discomfort of “the thing I was sure was broken turned out to be working,” and having to admit that meant the actual bug was somewhere I hadn’t looked yet. It’s a specific kind of frustrating to disprove your leading theory a week into hunting for something. The instinct is to keep pushing on the theory because you’ve already invested in it. I had to let it go and start over from “okay, if the inputs are right, where does the number actually go wrong.”

    The lead I ended up with, and haven’t fully closed yet: the projection math likely divides a current count by a ratio measured at a specific hour, and if that ratio is underestimated early in a shift, the division inflates everything downstream of it — a small early error compounding into a big late one. I don’t have it fully proven yet. But it’s a real, specific, testable hypothesis, which is further than “the forecast is wrong” ever got anyone.

    The transferable part isn’t the bug. It’s that “which system is actually running” is a question worth asking before “what’s wrong with the system,” every time — because the two questions send you down completely different paths, and only one of them is real.

    Next: instrumenting the actual division step directly, hour by hour, instead of trusting the summary numbers on either end of it.

  • The Pipeline That Runs While I Sleep

    I woke up on a Wednesday and checked Tower before I checked anything else.

    RALPH had fired at 2:47am. Detected a pattern in the task queue, ran a research chain, summarized the output, and filed it. No prompt from me. No session open. I was asleep. The system decided something was worth doing and did it.

    That’s the thing I built toward for two years of warrior sessions. Not the feature. Not the specific task RALPH ran that night. The fact that it happened without me.

    What’s Actually Running

    There are four systems on Tower right now that operate independently of my presence.

    PrivyBot is the oldest and most capable. It’s a personal autonomous AI assistant — Python, FastAPI, 131 MCP tools, running as an NSSM service on Tower. It has a priority queue, an async task loop, and RALPH: a persistent overseer that fires on schedule and monitors for things worth acting on. Email summaries. GitHub activity. YouTube analytics. Game metrics. It doesn’t wait for me to ask. It runs its own loop and surfaces what matters.

    The test floor is 557 passing, 0 failing. I know that number is real because I certified it myself. Every phase of PrivyBot’s development ended with that verification before the next phase started. 33 phases. The floor moved up each time, never down.

    ContentPipeline is the YouTube operation. I play games. The pipeline records the session with OBS, transcribes it with Whisper running on Tower’s GPU, identifies the moments worth keeping, generates captions, assembles the Short with FFMPEG, and schedules the upload. The calendar runs through July 2026 without me touching it. The pipeline built the calendar. I just played the games.

    TeleseroAdmin2026 runs during business hours without supervision. It watches six dialing servers, monitors list performance, and swaps underperforming lists automatically based on thresholds I defined. 262 tests. Full-auto loop. The intervention it was built to eliminate — me watching metrics and making manual swaps — hasn’t happened in months.

    DNC Automation runs on Cloud Run. Compliance checks that used to be a manual process, now a deployed service. Stable. I check it roughly every two weeks to confirm it’s still running. That’s the entirety of my interaction with it.

    How You Get There From a Warrior Session

    None of these started as systems. They started as scripts.

    PrivyBot started as a Telegram bot that could answer questions. TeleseroAdmin2026 started as a Python script named by date that logged into a portal and swapped one list. ContentPipeline started as a single produce_short.py file that required manual input at every step.

    The path from script to autonomous system is always the same and always takes longer than you expect.

    First you automate the thing you do most often. Then you notice the thing adjacent to it that you’re still doing manually. You automate that. Then you realize the two automations need to talk to each other, which requires a shared config. The shared config implies a shared schema. The shared schema implies a system.

    You don’t design the system. You discover it. The design document comes after, when you’ve accumulated enough automated pieces to see the shape of what they’re forming.

    The warrior sessions are how the pieces accumulate. Ninety minutes on a Tuesday night adds the encoding handler. Two hours on a Saturday adds the deduplication pass. A three-hour session where something clicked adds the orchestration layer that connects them. None of those sessions felt like building a system. They felt like solving the problem in front of you.

    At some point you look up and there’s a system.

    What Autonomous Actually Means

    I want to be precise about this because “autonomous” gets used loosely.

    Autonomous doesn’t mean unsupervised forever. It means the system handles the routine cases without requiring a human in the loop for each one. The edge cases still surface. The unexpected failures still need attention. The system doesn’t replace judgment — it handles volume so judgment is reserved for the things that actually need it.

    RALPH firing at 2:47am and running a research chain is autonomous. RALPH discovering a new class of task it’s never handled before and stopping to report it rather than guessing — that’s also autonomous, in a different direction. The system knows what it knows and flags what it doesn’t.

    TeleseroAdmin2026 swapping a list because a performance threshold was crossed is autonomous. TeleseroAdmin2026 encountering a portal login flow that changed after a site update and stopping the loop rather than proceeding incorrectly — still autonomous. The right behavior in an unexpected situation isn’t always to act. Sometimes it’s to stop and surface the situation.

    The systems I trust are the ones that fail loudly when they’re outside their design envelope. The ones that fail quietly — that continue operating in edge cases and produce confident, wrong output — those aren’t autonomous systems. They’re liability.

    This is the same principle I apply to coding agents. A system that tells you it succeeded when it didn’t isn’t a trustworthy system. Raw terminal output only. The floor is real or it isn’t.

    The Compounding

    Last Wednesday RALPH ran 14 tool calls before 6am. By the time I was at my desk, there was a digest waiting: yesterday’s YouTube performance, an alert on a campaign metric that drifted outside threshold, a summary of three GitHub commits I’d made the night before with notes on what each one changed.

    I didn’t ask for any of it. I configured the system to care about those things, and the system cared about them while I slept.

    That’s a different relationship with work than I had two years ago, when every piece of information about my projects required me to go get it. The information is still there. The systems go get it for me and bring back what matters.

    The compounding isn’t the time saved on any individual task. It’s the accumulation of context that’s available without friction. I sit down knowing the state of things because the systems maintained the state while I was away. The warrior sessions start from a known position instead of starting with reconnaissance.

    Why This Is the Pitch

    The consulting angle I’m building toward isn’t “I’ll automate things for you.”

    It’s “I’ll build systems that maintain themselves.”

    There’s a specific kind of buyer for this: operations managers at contact centers, at lead-generation companies, at any business where a significant portion of labor is humans doing deterministic work that could be encoded. They’ve heard about automation. They’ve seen demos. What they haven’t seen is someone who built it for themselves first, runs it in production, and can point to a floor that’s real because they certified it personally.

    The demo isn’t a slide deck. It’s Tower. It’s RALPH. It’s a system that was running while I slept and will still be running when this conversation ends.

    You can’t pitch autonomous systems credibly without having built them. You can’t build them without the warrior sessions. The sessions were never just sessions — they were the R&D for a product I hadn’t named yet.

    The Honest Accounting

    There are systems I built that aren’t running. Scripts that automated a task I stopped doing. Repos that solved a problem that no longer exists. Not every warrior session produces something that compounds — some of them produce something that was useful once and isn’t anymore.

    That’s fine. The return on the ones that do compound is high enough to cover the ones that don’t. PrivyBot is worth every session that went into it and several that went into things I’ve since discarded. ContentPipeline has scheduled more content than I’ve actively thought about. TeleseroAdmin2026 has run more dialing adjustments than I could have made manually in the same period.

    The pipeline runs while I sleep. That’s not a metaphor for anything. It’s a literal description of what happens between midnight and 6am on Tower.

    I built that. In the margins. One session at a time.

  • 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.