12 min read

I Built an AI Email Pipeline That Sends 450 Emails a Week. It's Manual on Purpose.

ai-mlengineeringbuild-in-publicpython

The pipeline sends about 450 personalized follow-up emails per week for my consulting company. Each one is generated by Claude, reviewed by Claude, and then reviewed by me before it goes anywhere near SendGrid. Three review gates for a marketing email.

That sounds absurdly over-engineered for outreach. It is. It also works better than anything fully automated I’ve tried.

The Problem

We’re a B2B software engineering and cloud consulting firm. We run marketing campaigns through a vendor who handles the initial outreach. When those campaigns generate opens and clicks, we need follow-ups. Personalized ones. Not the “Hi {FIRST_NAME}, just circling back” kind. Real emails that reference the recipient’s industry, connect to a relevant case study, and offer a specific engagement model.

The math: roughly 90 recipients per day across multiple campaign themes. Cloud adoption, systems integration, legacy modernization, scaling infrastructure. Each email needs to sound like it came from a person who actually looked at the company, not a mail merge that swapped in a logo.

For a moment, I was writing these by hand. That lasted about twenty minutes before I started thinking about automation.

Why Not Just Automate It?

The obvious move is to wire up the Anthropic API, feed it recipient data, and let it rip. I built that path too. It works. But here’s what I found:

Full API automation means you’re paying for retries. Structured output from an LLM isn’t always structured. When the JSON doesn’t parse cleanly, you retry. When the tone drifts mid-batch, you don’t notice until the emails are already queued. When the model decides to get creative with your greeting format, you’re debugging prompt adherence instead of reviewing emails.

The manual path costs less and catches more. A human Claude session with Sonnet for generation runs maybe $3 per million input tokens. Opus for review is $15 per million. For 90 emails, that’s negligible. But the real savings come from the fact that I’m looking at the output before it sends. I catch the email where the model confused two companies in the same industry. I catch the batch where every CTA defaults to the same engagement model. I catch the subject line that technically follows the rules but reads like spam.

Full automation would need to catch all of those things programmatically. Which means more code, more edge cases, more maintenance. For 450 emails a week, the economics don’t justify it.

There’s a Pareto angle here that’s easy to miss. The manual-first design captures roughly 80% of what full automation would give me (consistent voice, personalization that holds up, reliable delivery, usable reporting) for about 20% of the engineering effort. The remaining 20% of the value, the part full automation actually buys you, would cost 80% of the build. Retries, JSON repair, drift detection, same-institution collision guards, structural fuzzing, a feedback loop for bad generations. All doable. None of it free. At 450 emails a week, I’d be writing a framework to replace 30 minutes of daily review with something I probably wouldn’t trust as much as the review itself.

That’s not an argument against automation in general. It’s an argument that the automation boundary should be chosen deliberately, not assumed. Draw it at the point where your review time becomes the bottleneck instead of the safety net. Then refine it over time. Each batch teaches me something: a new source of drift, a rule that needs tightening, a personalization pattern that falls flat. I fold those findings back into SKILL.md after every pass. The manual loop isn’t just cheaper. It’s a feedback loop that makes the system better every week.

The Architecture

The pipeline is a Python CLI with eight commands. Here’s the actual flow:

prep takes the marketing vendor’s Excel export, filters recipients by engagement level (clicked > opened > delivered), removes opted-out addresses, enforces a 30-day cooldown between follow-ups per recipient, skips personal email domains, and generates batch-specific prompt files ready to paste into a Claude session.

Manual Sonnet session. I paste the generation prompt into Claude (Sonnet), which includes the batch’s recipient data, the voice rules, service descriptions, case studies, and structural constraints. Sonnet generates all emails for the batch in a single session. I save the output.

Manual Opus review. I paste the review prompt plus Sonnet’s output into a separate Claude session running Opus. Opus checks every email against the voice spec, catches structural violations, flags personalization that doesn’t match the recipient’s actual company, and rewrites where needed. I save the reviewed output.

import parses the reviewed markdown into structured JSON. The parser is strict. Every email has to match an exact field format: recipient, company identified, industry, confidence level, personalization tier, subject, body, and personalization notes. If the format is wrong, the import fails. No silent degradation.

approve is an explicit human gate. I review the imported batch and approve it for sending. This step exists because “import succeeded” and “I’ve actually looked at these” are different things.

send formats each email to HTML using a template that injects the signature block, CAN-SPAM footer, and UTM tracking. It sends via SendGrid with 5-15 second randomized jitter between emails. A batch of 90 takes about 7-22 minutes to send. Rate limiting is the point. I’m distributing the send load so it doesn’t look like a blast.

report queries SendGrid’s Email Activity API for delivery, open, and click status. It generates an Excel report with conditional formatting so I can see at a glance which emails performed and which bounced.

There’s also optout for suppression list management and batches for listing batch state.

Automated, AI-generated email pipeline architecture A marketing vendor's recipient export is filtered by prep, handed to a manual Claude Sonnet generation session and a manual Claude Opus review session, imported to JSON, approved by a human, sent through SendGrid, and reported on via SendGrid's Activity API. SKILL.md governs both Claude sessions. Filesystem artifacts persist at every stage. Automated, AI-Generated Email Pipeline Architecture Python CLI · Manual Claude sessions · SendGrid · Filesystem-persisted state External systems Marketing VendorXLSX exportdelivered · opened · clicked Anthropic APISonnet · Opusgeneration + review · structured markdown output SendGrid SendPOST /v3/mail/sendHTML · signature · UTM SendGrid ActivityGET /v3/messagesdelivered · open · click · bounce Pipeline stages (left → right) preppython -m cls_email prepingest XLSXfilter chain ↓emit generation prompt Sonnet sessionmanual, in Claude apppaste prompt + batch datagenerate ≈90 draftsapplies SKILL.md rules Opus reviewmanual, separate sessionvoice + structure checktier verificationrewrites in-place importcls_email importparse reviewed mdstrict JSON contractfail loud on drift approvecls_email approvehuman gatelast-mile reviewflips ready-to-send sendcls_email sendrender HTML + sig + UTM5–15s randomized jitterSendGrid dispatch reportcls_email reportquery Activity APIXLSX w/ conditional fmtper-recipient outcomes XLSX in Governance & pre-generation transforms prep filter chainEvery recipient row runs through these gates, in order: opt-outcooldown 30dskip personaltier split tier split routes by engagement: clicked → T3, opened → T2, delivered → T1 rejected rows land in suppressed / cooled / skipped buckets — not silently dropped Personalization tiersTier 1 — name + topic (baseline)Tier 2 — + company + industry angle (default)Tier 3 — + research signal (funding, migration, hiring)Sonnet targets the tier · Opus verifies it was actually delivered SKILL.md — single source of truth~200-line governance file covering:voice rules · greeting format · CTA librarysubject variation · case-study rotationparser contract · personalization tiersloaded into both Sonnet & Opus sessions Filesystem artifacts persisted at each stage prep outputbatches/NNN.prep.jsonNNN.prompt.md Sonnet outputNNN.generated.mdsaved by hand Opus outputNNN.reviewed.mdcanonical pre-send copy parsed batchNNN.jsontyped records approval flagstatus: approvedtimestamp + operator send recordsNNN.sent.jsonlper-email SendGrid id report artifactNNN.report.xlsxconditional formatting optoutmaintain suppression listwrites to opt-out filter gate in prep batchesinspect all batch statesread-only view over the filesystem Error paths (detailed in state diagram)parse_failed · rejected · send_failed · bouncedevery failure routes back to a recoverable state Human gatesSonnet · Opus · approve — no automated sendthe pipeline is designed to fail safe, not move fast LegendPython CLI commandManual Claude sessionHuman gateExternal API / inputGovernance fileFilesystem artifactUtility commandError / recovery path Dashed lines show persistence (stage → artifact) or governance (SKILL.md → Claude sessions). Solid lines are data flow.
Figure 1. Automated, AI-generated email pipeline architecture. Vendor XLSX export flows through prep, a manual Claude Sonnet generation session, a manual Claude Opus review session, import, an approve human gate, send, and report. SKILL.md governs both Claude sessions. Every stage writes a persistent artifact to disk.

How to read this diagram. There are four horizontal lanes. The top lane shows the external systems the pipeline talks to: the marketing vendor’s XLSX export, the Anthropic API (Sonnet for generation and Opus for review), and two SendGrid endpoints (Send for dispatch, Activity for telemetry). The second lane is the pipeline proper, reading left to right: prep, Sonnet session, Opus review, import, approve, send, report. Blue boxes are Python CLI commands; dashed amber boxes are manual Claude sessions I run by hand; the red-bordered approve box is an explicit human gate. The third lane zooms in on two things that happen before Sonnet ever sees a recipient: the prep filter chain (opt-out → cooldown 30d → skip personal domains → tier split) and the personalization tier routing (Tier 1 name+topic, Tier 2 +company+industry, Tier 3 +research signal). The purple SKILL.md box is the governance file pulled into both Claude sessions. The fourth lane shows the filesystem artifact each stage persists: NNN.prep.json, NNN.generated.md, NNN.reviewed.md, NNN.json, an approved status flag, NNN.sent.jsonl, and NNN.report.xlsx. Utility commands optout and batches sit off to the side; they mutate or inspect state without advancing a batch through the pipeline.

The State Machine

Every batch moves through a predictable sequence: generated → merged → approved → sent → reported. Each transition persists state to JSON. This matters because the pipeline isn’t a single script you run end-to-end. It’s a workflow you step through over the course of a day.

I might prep batches in the morning, run the Sonnet sessions after lunch, do the Opus review in the evening, and send the next morning. If something interrupts me, I pick up wherever I left off. The state files tell me exactly where each batch is.

This is the same principle I use in my agentic development system for code: durable state on the filesystem, not in memory. If a session dies, the work survives.

CLS email pipeline batch state machine Every batch moves through eight canonical states — prepped, generated, reviewed, merged, approved, sending, sent, reported — with six recoverable error states (suppressed, cooled, parse_failed, rejected, send_failed, bounced). Transitions are triggered by CLI commands or manual Claude sessions. Two transitions are explicit human gates: Opus review and approve. Every state persists to disk as JSON so interrupted workflows resume cleanly. Batch State Machine — Happy Path, Errors, and Recovery Every transition persists state to disk. Any interrupted batch resumes exactly where it left off. prepped filtered recipients prompt emitted NNN.prep.json generated Sonnet drafts saved by hand NNN.generated.md reviewed Opus-edited drafts voice + tier verified NNN.reviewed.md merged parsed to JSON typed records NNN.json approved human gate ready-to-send flag status: approved sending in-flight to SendGrid 5–15s jitter per email NNN.sending.jsonl sent SendGrid accepted message id captured NNN.sent.jsonl reported Activity API polled open · click · bounce NNN.report.xlsx Sonnet manual session Opus review manual · human gate import strict JSON contract approve human gate send dispatch loop ack per-email 2xx report poll Activity API prep suppressed opt-out or bad domain terminal — never generated cooled inside 30-day window requeue after cooldown parse_failed reviewed md doesn't match parser contract → edit Opus output, re-import rejected operator declines batch → back to generated, fresh Sonnet pass send_failed SendGrid 4xx / 5xx → retry per-email, then dead-letter bounced reported by Activity API → auto-add to suppression list filter cooldown import fails re-import declined regenerate · fresh Sonnet pass SendGrid error retry hard bounce auto-suppress for future batches batches read-only view of every batch's current state optout append to suppression list → affects future prep runs Durability guarantee every state persisted to disk as JSON — a killed process or closed laptop loses zero work Legend Automated state Manual Claude session Human gate Terminal (success) Error / exception state Recovery transition Error transition
Figure 2 — Batch state machine. Eight canonical states on the happy path (prepped → generated → reviewed → merged → approved → sending → sent → reported) with six recoverable error branches. Every transition persists to disk as JSON, so an interrupted batch resumes exactly where it stopped.

How to read this diagram. The top lane is the happy path, left to right: prepped → generated → reviewed → merged → approved → sending → sent → reported. Blue boxes are automated states; dashed amber boxes are manual Claude sessions (Sonnet, then Opus); the red box is a human gate (approve); the green box is terminal success (reported). Every arrow is labeled with the command or session that triggers the transition. The middle lane holds the six recoverable error states that branch off from the happy path: suppressed and cooled from prepped (recipient filtered out or still inside the 30-day cooldown window), parse_failed when the reviewed markdown doesn’t match the parser contract, rejected when an operator declines the batch at the approve gate, send_failed on SendGrid errors, and bounced when the Activity API reports a hard bounce. Red dashed arrows show failure transitions; green dashed arrows show recovery transitions (re-import after fixing the Opus output, regenerate from a fresh Sonnet pass after a rejection, retry per-email on send failure). The dotted red arc from bounced back to suppressed is the auto-suppression feedback loop. A hard bounce writes to the suppression list so future prep runs filter the address at the gate. The bottom lane covers the two utility commands (batches for read-only inspection, optout for manual suppression-list maintenance) and the durability guarantee: every state lives on disk as JSON, so a killed process or closed laptop loses zero work.

The Voice Governance Problem

Here’s where it gets interesting. Generating 450 emails a week means you need consistency. Not identical emails. Consistent voice, consistent quality, consistent adherence to rules that exist for good reasons.

The rules aren’t arbitrary. Subject lines can’t repeat more than 4 times per 20-email batch because email providers flag repetition. No single service offering can appear in more than 40% of a batch because we want to showcase the full range. Every case study needs to appear at least twice per 10 emails because they’re all real outcomes worth sharing. Same-institution recipients need different subject lines, service angles, and case studies because two people at the same company comparing notes on identical emails kills credibility instantly.

All of this lives in a governance system anchored by a single file: SKILL.md. The core file is around 200 lines, but it references companion files for voice rules, case studies, service descriptions, and structural examples. Together they form the source of truth for every email the pipeline generates. Voice rules, structural patterns, greeting format, personalization tiers, CTA requirements, variation constraints, the parser contract. Everything.

The architecture matters more than the size. SKILL.md is the entry point, and because it’s a single root file, I can read it in one sitting, diff it cleanly, and hand it to a new Claude session as context without tripping over token budgets. The companion files get pulled in as needed.

When I need to change how emails sound, I change one file. The generation prompts, the review prompts, and the manual workflow all reference SKILL.md as the authority. There’s no prompt drift because there’s only one source.

This is the same architectural pattern I use for software development in my agentic development system. There, it’s called AGENTS.md. Different domain, same idea: a single contract that governs how the AI operates, what it’s allowed to do, and what “done” looks like.

What the Personalization Actually Looks Like

Not all emails get the same treatment. The pipeline uses three tiers:

Tier 1 is the baseline. Every email gets the recipient’s name and a topic-relevant angle. This is the minimum.

Tier 2 adds the company name, industry, and a sentence connecting the topic to their specific sector. Most emails land here. It’s enough to feel personal without requiring deep research on every recipient.

Tier 3 is research-informed personalization for high-value prospects. A recent funding round, a public infrastructure migration, a job posting that signals they’re scaling. This tier requires actual investigation, so it’s reserved for accounts worth the time.

The generation prompt tells Sonnet which tier to target for each recipient based on company data quality and engagement signals. Opus checks that the tier was actually delivered. If Sonnet claims Tier 2 but the email doesn’t reference the company’s industry, Opus flags it.

What I’ve Learned

Manual doesn’t mean blocking. The pipeline is manual, but I’m not sitting in front of it for hours. A day’s batch of about 90 emails involves maybe 30 minutes of actual attention from me, spread across the day. I kick off a Sonnet generation session, switch to other work while it runs, come back to paste the output into the Opus review, switch to other work again, then do my final review and send when I’m ready. The Claude sessions are doing the heavy lifting. I’m just the human gate at the critical transitions.

That multitasking pattern is the thing I keep coming back to across every AI system I build. The productivity win isn’t that one agent is fast. It’s that multiple agents can be running, on different projects, in different contexts, while I’m giving my attention to whichever one needs a decision right now. I’ll write more about this soon — the shift from “wait for the AI” to “the AI waits for me” is the real unlock.

State machines are underrated for AI workflows. Every AI pipeline I’ve built ends up with the same pattern: explicit states, persisted transitions, human gates at the critical points. The temptation is always to make it end-to-end. The reality is that end-to-end means you don’t notice problems until they’ve already shipped.

A single source of truth for AI behavior is non-negotiable. Whether it’s SKILL.md for email generation or AGENTS.md for code generation, having one file that defines how the AI should behave solves an entire category of problems. Prompt drift, inconsistent output, rules that contradict each other across different prompts. One file, one authority.

The review step pays for itself. Opus catches things I miss, and I catch things Opus misses. The combination of LLM review plus human review is meaningfully better than either alone. The errors Opus catches tend to be structural: a CTA that doesn’t name an engagement model, a subject line that’s been used too many times. The errors I catch tend to be contextual: a company description that’s technically correct but feels off, a tone that’s too aggressive for a first follow-up.

Where This Goes Next

Manual-first is right today. It isn’t the plan forever. Trying to scale the current approach would be absurd. At some point, the Pareto math flips. The part that feels expensive now — retries, JSON repair, drift detection, same-institution collision guards, a feedback loop for bad generations — is exactly what gets refined with every new generation.

So the long-term plan is full automation, but it’s earned automation. Every piece I hand over to the system should be one where I’ve personally seen enough batches to know what the failure modes actually look like. That’s the whole reason the pipeline is manual-first. By the time I automate the Opus review, I’ll have opinions about what Opus reliably catches and what it misses, and those opinions will be grounded in batches I actually read, not in what I imagine happens.

The pipeline runs locally right now. Python CLI, local file storage, manual Claude sessions. The admin app is already scaffolded in Next.js for a cloud migration: Firebase auth, Firestore for batch state, Cloud Storage for artifacts, Cloud Run for sending. The architecture doesn’t change. The state machine stays the same. It just moves from my laptop to infrastructure other people on the team can access, which is step one toward the automation path.

I’m also considering open-sourcing a stripped-down version. The recipient filtering, batch state management, SendGrid integration, and reporting pipeline are useful patterns regardless of whether you’re sending marketing emails or any other kind of structured AI-generated content. If that sounds interesting, I’d like to hear about it.

The bigger insight, the one I keep coming back to across every AI system I build, is that the constraints are the product. The AI generates. The contracts govern. The state machines ensure nothing ships without someone saying “yes, this is ready.” Manual-first isn’t a rejection of automation. It’s the path to automation you can trust.


Discussion

Comments powered by GitHub Discussions. You'll need a GitHub account to participate.