# The 9-Agent AI Outreach System: Technical Documentation

**Platform:** [n8n](https://n8n.io) (self-hosted or cloud, doesn't matter which)
**Database:** [Supabase](https://supabase.com) (free tier is enough to start)
**Research:** [Tavily](https://tavily.com) search API
**Email verification:** [AbstractAPI](https://www.abstractapi.com/) Email Reputation
**Writing/judging:** any capable LLM with a chat-completions style API (this build uses Azure OpenAI GPT-5.4; GPT-4o, Claude, or Gemini work the same way, you just change one HTTP node)

This document is the technical reference for the system shown in the companion video. If you want the "why should I build this" pitch, read [ARTICLE.md](ARTICLE.md) first. If you want the "how do I build it, step by step" guide, read [BUILD-YOUR-OWN.md](BUILD-YOUR-OWN.md). This file is the deep reference for what each of the 9 agents actually does and why it's built that way.

The example niche used throughout (elder care / senior living) is illustrative only, the pipeline is industry-agnostic. Swap the prompts and target list, and it hunts any B2B niche with a definable ideal customer.

---

## 1. The pipeline (9 agents)

Each stage is a **separate n8n workflow**. They hand work off through shared Supabase tables (mainly `prospects.status`), this is deliberately **event-driven, not one giant workflow**. That single decision is what makes the system debuggable, restartable, and safe to edit one piece at a time without breaking the rest.

```
Agent 1  Prospect Hunter        → finds companies matching your ICP        → prospects (new)
Agent 2  Contact Enrichment     → finds decision-maker + verified email    → prospects (enriched)
Agent 3  Email Writer           → structured, personalized draft          → outreach_log (pending)
Agent 4  Digest & Sender        → verified digest → you approve → sends   → outreach_log (sent)
Agent 5  Reply Tracker          → detects replies                         → outreach_log (reply_received)
Agent 6  Bounce Handler         → bounce/verify-fail → verified alternate → prospects / outreach_log
Agent 7  Follow-up Sequencer    → in-thread follow-ups                    → outreach_log
Agent 8  Focused Leads          → VIP repliers + daily reminders          → focused_leads
Agent 9  API Key Manager        → self-service key refill, no downtime    → (updates other agents)
```

**The core loop, in one sentence:** find a company → find a real human + a real email → write them something worth reading → let a human approve before it sends → track what happens → never let a good conversation go stale.

**Human-in-the-loop is a deliberate design choice, not a limitation.** Every cold email that leaves this system was read and approved by a person first (Agent 4's digest). Fully autonomous sending is technically trivial to add, the reason not to is deliverability and quality control, not engineering difficulty.

---

## 2. Data model (Supabase)

Six tables, two triggers. The full `CREATE TABLE` / trigger SQL is in [BUILD-YOUR-OWN.md](BUILD-YOUR-OWN.md) §2, this section explains what each table is *for*.

### `prospects`: master CRM (one row per company)
The spine of the whole system. Columns: `company`, `website`, `city`, `country`, `niche`, `founder_name` (+ a `key_people` JSON array for secondary contacts), `email`, and a `status` field that walks through the pipeline:

```
new → enriched → approval_sent → followed_up
```

Plus verification bookkeeping: `email_valid` (boolean, set by the verification layer, see §4), `email_status` (`verified_deliverable` / `verified_unknown` / `no_valid_email` / `bounced` / `alt_verified` / etc.), and `alt_*` fields for a bounce-replacement contact.

### `prospect_emails`: per-address candidate ledger
Enrichment often turns up more than one plausible email per company (info@, the founder's personal address, a generic sales@). Every candidate gets its own row here with a verification `status` (`verified` / `invalid` / `pending`) and `status_detail` (the raw verification verdict). `is_active` flags whichever one actually gets used, the first *deliverable* one, not blindly the first one found.

### `outreach_log`: one row per outreach email
This is the audit trail: `to_email`, `email_subject`, `email_body`, `approval_status` (`pending` → `sent`/`skipped`/`expired`), `sent_at`, `bounced`, `reply_received`, `follow_up_count`, and, critically, `gmail_message_id` / `gmail_thread_id`, stamped at send time. Agent 7 (follow-ups) reads **its own row's stored thread ID** to reply in-thread. It never does a live mailbox search to "find" the original email. That one design choice avoids an entire category of bugs (see §6, rule 12).

### `scrape_coverage`
A ledger of which (city × niche) search combinations have already been mined, so Agent 1 doesn't burn search credits re-querying the same combo every day. A combo is marked `exhausted` once it stops returning new companies, and gets retried after a cooldown window (this build uses 21 days) in case new companies appear.

### `focused_leads`
A short list for genuine repliers, the people worth your personal attention, separate from the bulk pipeline. Agent 8 populates and nurtures this.

### `activity_log`
A flat event log (`workflow`, `action`, `count`, `details`, timestamp) a few agents write to, used to build the "last 24 hours" stats block in the daily digest.

---

## 3. The agents in detail

### Agent 1: Prospect Hunter
**Job:** turn a target-customer definition into a list of real companies.
**Trigger:** twice a day (morning + evening).

The core building block is a code node that generates a batch of **(city, niche) search combinations** for the run, this keeps runs fast and cheap instead of trying to search everything at once. Each combination becomes a Tavily search query, and an LLM call reads the raw search results and extracts a structured list of qualifying companies: name, website, city, why it qualifies.

**"Qualifying" is defined explicitly in the prompt**, not left to the model's judgment, target company size/spend tier, explicit exclusions (no charities, no government programs, no obviously-budget operators if you're targeting premium clients), and per-niche criteria if you're hunting more than one niche at once. This single prompt is the highest-leverage piece of the entire system to get right: a vague ICP definition here means every downstream agent (enrichment, writing, sending) wastes effort on companies that were never going to reply.

New companies get deduped against the existing `prospects` table (by normalized company name + website) before insertion, and the combo gets logged to `scrape_coverage` regardless of whether it produced results, that's what lets the system self-throttle instead of grinding the same dry well every day.

### Agent 2: Contact Enrichment
**Job:** turn a company into a real person + a real, verified email address.
**Trigger:** runs several times a day, batching a fixed number of prospects per run (tuned to slightly outrun what Agent 3/4 can draft and send, so the queue never backs up or starves).

Chain per prospect: search for the decision-maker (founder > CEO > MD > director, in that priority order) → LLM extracts name + role + LinkedIn → search for their likely email → LLM extracts candidates → **every candidate gets run through the email verification API** before anything is trusted. The first *deliverable* result wins; an "unknown/catch-all" result is kept as a sendable fallback rather than discarded, since a lot of legitimate business inboxes verify as catch-all.

A second pass crawls the company website and has the LLM judge fit, "does this company actually look like it can afford and needs what I'm selling?", using industry-relative signals (team size, service tier language, existing marketing sophistication), not a hardcoded checklist. This is the single most niche-specific prompt in the whole system; if you clone this for a different industry, rewrite this one carefully.

Prospects where **no candidate email verifies as deliverable** get flagged `email_valid=false` rather than silently dropped, Agent 6's bounce/invalid sweep picks them up later and tries again.

### Agent 3: Email Writer  (the heart of the system)
**Job:** produce a genuinely personalized cold email, without ever producing something so generic it reads as spam, or so free-form that it breaks structure.
**Trigger:** twice on weekdays, processing prospects one at a time in a loop (not in bulk, see §6, rule on why).

This agent went through more redesigns than the other eight combined, for a specific, repeatable failure mode worth understanding if you build something similar: **give an LLM a long, accumulated list of formatting rules ("always include X, never say Y, must be between A and B words, must end with Z...") and eventually it silently drops something under the weight of all the other rules, and if you also have an LLM *scoring* the output against those same rules, the scorer correctly fails it, and your draft output rate quietly goes to zero.** This happened twice in production before the fix stuck.

**The fix, structure in code, judgment in the model:**
The LLM is asked to write **only the genuinely creative parts**, an opening observation, a "why this matters to you" line, a "what I'd actually do" line, and a call-to-action. Everything else, the greeting (built to handle honorifics gracefully: "Dr. Anna Wolfe" → "Hi Anna,"), the subject line (chosen from a rotating set of presets, personalized with the company name), the trust/credibility line, the sentence-per-paragraph formatting, the sign-off, is **assembled deterministically by code** immediately after. A required section can no longer be "forgotten," because it was never optional in the first place. **This is the single most important lesson in this whole repo: structure belongs in code, judgment belongs in the model.**

**Quality gate chain**, in order:
1. An LLM **scorer** rates only the 3-4 creative fields (not the whole assembled email) on personalization quality, 1-10. ≥7 passes. The scorer prompt explicitly includes an anti-strictness calibration clause, without one, scorers drift stricter over time and eventually reject everything (the same failure mode as above, one level up).
2. One retry with the scorer's feedback folded back into the prompt.
3. A **deterministic hard gate**: word count band, no leftover template placeholders, no signature the model tried to invent, no em dashes (a mechanical find-and-replace catches anything the prompt didn't), sentences forced one-per-line.
4. Fails the hard gate → one gate-rewrite attempt → still fails → parked as `low_score` rather than sent. Nothing reaches the send queue unverified.

**A zero-drafts alarm** emails you if a run parks 3+ drafts and successfully writes 0, the early-warning signal that would have caught both historical incidents same-day instead of days later.

### Agent 4: Daily Digest & Sender
**Job:** the human checkpoint. Nothing sends without a click.
**Trigger:** one daily digest email, plus real-time webhook actions.

Before the digest is even built, **every pending draft is re-verified** for deliverability (cache-first, so this is nearly free after the first check). The digest shows only sendable emails, with a clear "N verified valid emails ready to send" banner. Anything that comes back undeliverable gets marked and routed to the bounce-handling flow instead of being shown as a live option.

Each row in the digest has one-click actions served via n8n webhooks: **Send**, **Skip**, **Edit** (opens a small form, resubmits, re-gates), **Rewrite** (regenerate with feedback). There's also an **Approve All** for clearing the queue fast, capped at a daily sending limit with random 1-3 minute spacing between sends (cold outbound from a single mailbox has real-world daily caps before deliverability suffers, see §4 below).

**Every send stamps `gmail_message_id` and `gmail_thread_id` on the outreach_log row.** This one field is what lets Agent 7 send follow-ups *in the same email thread* reliably instead of guessing.

If you outgrow a single sending mailbox, the pattern that worked here is **sticky hash-based routing**: `hash(prospect_id) % 100 < WEIGHT` decides which of two (or more) mailboxes sends to a given prospect, and the same prospect always gets the same sender, which matters, because follow-ups need to come from the same mailbox as the original email.

### Agent 5: Reply Tracker
**Job:** notice when someone replies, and stop bothering them.
**Trigger:** polls the inbox every minute.

Matches replies by sender domain against `outreach_log`, marks `reply_received=true`, applies a Gmail label for visibility. That one boolean is the single gate every downstream agent (follow-ups, digests) checks before touching a prospect again, simple on purpose, because this is the one flag that must never have a false negative.

### Agent 6: Bounce Handler
**Job:** when an email bounces, don't just give up on the company, find a working alternate contact.
**Trigger:** hourly bounce poll + a periodic sweep of prospects already flagged invalid + a daily catch-up scan (see §6, rule on why a "daily catch-up" exists at all, it closes a timing race the hourly poll alone can't).

Detects bounce notifications, searches for an alternate contact at the same company, and, critically, **verifies the alternate before trusting it**, the same as Agent 2's original enrichment. An unverified "alternate" that also bounces just wastes another send. A verified alternate re-validates the prospect and, if there's an unsent draft still sitting in the queue, repoints it to the new address automatically.

### Agent 7: Follow-up Sequencer
**Job:** nudge prospects who haven't replied, on a cadence, without ever cross-threading two different people's conversations.
**Trigger:** twice on weekdays; a day-3 / day-7 / day-14 cadence, capped per run, with its own approval step (same digest-and-click pattern as Agent 4).

**The one bug worth knowing about before you build this yourself:** the naive way to thread a follow-up is to search the mailbox for the original email and reply to whatever you find. Under batch processing, if that lookup uses an "only look at the first item" reference inside a node that's actually processing many items, *every follow-up in the batch replies into the first prospect's thread*, a genuinely nasty failure mode to catch late. The fix: **each follow-up reads its own row's stored thread ID**, processed strictly one item at a time, no live mailbox search, ever. If a row is somehow missing a thread ID, it sends fresh rather than guessing, a fresh email to the wrong-seeming thread is recoverable; a follow-up landing in a stranger's inbox thread is not.

Copy follows a "meeting-close ladder" across the three touches, an easy yes/no choice on day 3, a lower-commitment reframe (referral, "just send the findings") on day 7, a polite door-still-open close on day 14.

### Agent 8: Focused Leads
**Job:** give genuine repliers, the highest-value people in the pipeline, a lighter-touch, more personal nurture track instead of getting lost in the bulk system.
**Trigger:** daily sync of repliers into `focused_leads`, daily reminder email so they don't go cold waiting on you.

### Agent 9: API Key Manager
**Job:** never let the whole pipeline stall because a free-tier API key ran out of quota.

A simple webhook-backed form: paste a new API key, it auto-detects which service it belongs to (by key format), **live-tests it** before accepting, then distributes it into the key-rotation array used by every agent that calls that service, and re-verifies anything that was stuck waiting on quota. Every alert email that mentions "quota exhausted" links straight to this form. This is the piece that turns "the whole system silently stopped 3 days ago" into "one click, back to normal in 10 seconds", worth building even in a v1, because free-tier API quotas *will* run out at the worst time.

---

## 4. External services & why these specific ones

- **Writing/judging LLM:** any provider works, this build uses Azure OpenAI, but the node is a plain HTTP call to a chat-completions-shaped endpoint. Swapping providers is a five-minute change to one or two nodes, not a rebuild. If you use a reasoning model, budget generously for `max_tokens`, reasoning tokens count against the same budget as output tokens, and a too-small cap silently returns empty content instead of erroring.
- **Web research: Tavily**, not a general-purpose LLM "web search" tool built into your model provider. **This is the single costliest line item to get wrong.** A built-in web-search tool on a major cloud AI platform can bill per-query at rates that look trivial in a demo and scale into a serious surprise at production search volume, worth catching in a cost review before it shows up on an invoice, not after. A dedicated search API (Tavily's free tier alone is 1,000 credits/month, paid tiers are predictable and cheap) with **key rotation across multiple free accounts** costs a fraction of that for equivalent volume. Rule of thumb: **put a hard spend cap on any AI-provider "web search" tool before you ever run it in production**, or skip it entirely and use a dedicated search API instead.
- **Email verification: AbstractAPI** Email Reputation, free tier is ~100 credits/account, which sounds small until you notice that a 30-day cache per unique email means each address only ever costs credits once or twice total (once at enrichment, once more only if it's re-checked at send time and the cache expired). Key signals worth knowing if you build this: verification APIs typically distinguish "deliverable" from "unknown/catch-all", treat both as sendable, only "undeliverable" as a hard block. Watch for quota-exhausted responses coming back as unusual HTTP codes (this API used 422, not the more obvious 429), read the actual response body, not just the status code, to detect quota exhaustion reliably. Respect the provider's rate limit with jittered delays and backoff, not just retries.

---

## 5. Operational cheat-sheet

Useful SQL for debugging a live system:
```sql
-- Where is this prospect in the pipeline?
select company, email, status, email_valid, email_status, alt_email
from prospects where email ilike 'someone@example.com';

-- What happened to an email that was sent?
select to_email, approval_status, sent_at, gmail_thread_id, reply_received, bounced, follow_up_count
from outreach_log where to_email ilike 'someone@example.com';

-- Which search combos have been exhausted?
select city, niche_slug, unique_found, exhausted, scraped_at
from scrape_coverage order by scraped_at desc;
```

- **Un-park low-scored drafts:** `update prospects set status='enriched' where status='low_score' and updated_at >= '<date>';`, re-queues them for a fresh draft attempt.
- **Reset a city's search coverage:** `delete from scrape_coverage where city ilike '<city>';`
- **Scaling send volume:** raise the daily digest/send caps gradually, never all at once, a cold mailbox's deliverability degrades fast if you jump straight to high volume. Ramp over 2-3 weeks.

---

## 6. Engineering rules: hard-won lessons, read before you edit anything

Every one of these is a real production incident. If you're building something similar in n8n (or honestly, in any workflow-orchestration tool), each of these will eventually bite you too, cheaper to read them now.

1. **A getAll/lookup node used as a dedup or gate feeding a downstream node must always output data, even on zero results**, otherwise most workflow tools halt the branch entirely when a query returns nothing, and if that same branch is the *only* thing that would ever populate the table you're querying, you've built a permanent deadlock that looks like "the system just stopped."
2. **A getAll/lookup node that ignores its input should run once, not once per incoming item**, the default in many workflow tools is "re-run for every item that reaches this node." Forgetting to fix that turns a 20-item batch into a 20x-cost lookup, or worse, a lookup-inside-a-lookup that fans out multiplicatively.
3. **Multi-condition filters default to OR in a lot of workflow-builder UIs, not AND.** This is the single most dangerous default in this entire stack. An "unsent AND not-replied AND not-bounced" filter that's silently OR'd together will happily return already-sent, already-replied rows too, and if that feeds a "send" action, you will re-send emails to people who already got them. Catch this in testing, before it ships.
4. **Never reference "just the first item" inside a node that processes a batch.** If a node can receive multiple items, treat every item independently. Reading "the first item" inside a batch context is exactly how one follow-up ends up threaded into a different prospect's conversation.
5. **A node with two inbound branches runs its whole downstream chain once per branch that reaches it**, in some workflow tools, meaning a naive "fan-in" (two paths converging back into one shared continuation) can silently double-execute everything downstream. Keep expensive/side-effecting chains on a single linear path; do lookups sequentially and reference earlier results directly instead of merging branches back together.
6. **Structure belongs in code, judgment belongs in the model.** Any output requirement that's genuinely non-negotiable (a required section, a length limit, a banned phrase) should be enforced by code after generation, not just requested in the prompt. Prompts are judgment, not contracts.
7. **Give any LLM that's scoring/gating another LLM's output an explicit anti-strictness clause**, and never let "banned phrases" or "automatic fail" rules accumulate indefinitely, a scorer's effective strictness creeps upward every time you add one more rule, until eventually nothing passes.
8. **Alert/notification logic needs both a "once per run" AND a "once per day" guard.** A per-item alert with no guard, triggered inside a loop, will happily send you twenty duplicate alert emails in the same minute the first time something actually goes wrong.
9. **Refresh any visual workflow editor before you touch it.** A stale open tab can silently revert live changes if you save from it, this is a UI-tool gotcha, not a logic bug, but it destroys work just as effectively.
10. **Don't fetch two large tables into the same execution unattended.** Pulling "everything" from two big tables in one run, especially anything with long text fields, can crash or restart a self-hosted workflow instance. Fetch one table per execution, reduce in code immediately, and only fetch what the next step actually needs.
11. **Every human-approval send path must re-validate eligibility at send time, not just at draft time.** A draft can sit in an approval queue for days; if the prospect replies or bounces in the meantime, the send button must recheck before firing, not trust a stale queue.
12. **Check your workflow platform's caching behavior around scheduled/webhook triggers after an API-based update.** Some platforms don't reliably reload a running scheduled trigger's in-memory copy from an API-pushed change, verify the *next actual scheduled run* reflects your edit, don't just trust that "saved" means "live."

---

## 7. What this is not

This is a lead-generation and cold-outreach system with a human approval gate, not a spam tool, and not built to bypass anyone's inbox protections. It sends personalized, individually-approved emails at a volume any single mailbox can sustain (tens per day, not thousands), tracks replies and bounces properly, and stops the moment someone replies. If you build this yourself: respect deliverability norms, warm up any new sending mailbox gradually, and never send anything you wouldn't be comfortable being asked about directly by the person who received it.
