# Build Your Own: Step-by-Step Setup Guide

This guide walks you from zero to a running 9-agent outreach system using the workflow files in [`workflows/`](workflows/). Read [documentation.md](documentation.md) first if you haven't, it explains *why* each agent is built the way it is. This file is just the *how*.

**Time estimate:** a focused afternoon to get all 9 agents importing and wired up; a few days of low-volume testing before you trust it with real sends.

---

## 0. What you're setting up

| Piece | Count | Where it comes from |
|---|---|---|
| n8n workflows | 9 (in `workflows/`) | Import the JSON files directly |
| Supabase tables + triggers | 6 tables, 2 triggers | Run the DDL in §2 |
| Credentials | Supabase, Gmail, your LLM provider, Tavily, AbstractAPI | You create these fresh, see §3 |
| Webhook paths | ~10 across Agents 4 & 7 | Already generic placeholders in the JSON, rename them to something unique to you |

**Every workflow JSON in this repo has had all credentials, API keys, and personal identifiers stripped.** On import, n8n will show each node missing a credential, that's expected. You'll assign your own in §3.

---

## 1. Prerequisites

- An n8n instance (self-hosted is free; n8n Cloud works too). This system runs fine on a small VPS.
- A Supabase project (free tier is enough to start).
- A dedicated Gmail (or Google Workspace) address for sending, **do not use your primary inbox.** Cold outbound needs its own reputation to protect, and reply/bounce detection needs a mailbox nothing else writes to.
- API keys for:
  - **Tavily** (search), free tier gives 1,000 credits/month; sign up for 2-3 accounts if you want built-in key rotation.
  - **AbstractAPI** Email Reputation (verification), free tier ~100 credits/account, same rotation logic applies.
  - **An LLM provider**, OpenAI, Azure OpenAI, Anthropic, or anything with an HTTP chat-completions-style API.

---

## 2. Database setup (run once in the Supabase SQL editor)

```sql
-- ===== core tables =====
create table prospects (
  id uuid primary key default gen_random_uuid(),
  company text, niche text, city text, country text, website text,
  founder_name text, founder_linkedin text, key_people jsonb default '[]',
  person2_name text, person2_linkedin text,
  email text, status text default 'new',
  seo_pain_points text, briefing_note text,
  primary_service_fit text, raw_website_data text, source text,
  premium_fit text, premium_signals text,
  email_valid boolean not null default true,
  email_status text check (email_status in ('valid','bounced','send_failed','replaced')),
  alt_email text, alt_contact_name text, alt_contact_role text,
  alt_contact_email text, alt_contact_linkedin text,
  priority boolean default false, last_contacted date, created_date date,
  inserted_at timestamptz default now(), updated_at timestamptz default now()
);

create table outreach_log (
  id uuid primary key default gen_random_uuid(),
  prospect_id uuid references prospects(id),
  to_email text, company text, website text, linkedin text, founder_name text,
  pain_points text, city text,
  email_subject text, email_body text, linkedin_message text,
  approval_status text default 'pending', quality_score int,
  sent_date date, sent_at timestamptz,
  bounced boolean default false, bounce_date date, alt_email_found text,
  reply_received boolean default false, reply_date date,
  follow_up_count int default 0, followup_subject text, followup_body text,
  followup_pending boolean default false, last_followup_at timestamptz,
  gmail_message_id text, gmail_thread_id text,
  inserted_at timestamptz default now(), updated_at timestamptz default now()
);

create table prospect_emails (
  id bigint generated always as identity primary key,
  prospect_id text not null, email text not null,
  person_name text, person_role text, linkedin_url text,
  rank int not null default 1, confidence text, source text,
  is_active boolean not null default false,
  status text not null default 'pending',
  status_detail text, sent_at timestamptz,
  updated_at timestamptz not null default now(),
  created_at timestamptz not null default now(),
  unique (prospect_id, email)
);
create index idx_pe_prospect on prospect_emails (prospect_id);
create index idx_pe_status on prospect_emails (status);

create table focused_leads (
  id uuid primary key default gen_random_uuid(),
  company text, website text, city text, email text, linkedin text,
  why_hot text, pain_points text, how_i_can_help text, reachout_ideas text,
  outreach_status text, followup_count int default 0, next_followup date,
  converted boolean default false, last_updated date,
  inserted_at timestamptz default now()
);

create table scrape_coverage (
  id uuid primary key default gen_random_uuid(),
  city text, country text, niche text, niche_slug text,
  unique_found int, raw_found int, exhausted boolean default false,
  scraped_at timestamptz default now()
);

create table activity_log (
  id uuid primary key default gen_random_uuid(),
  summary text, workflow text, action text, count int, details text,
  company text, link text, created_at timestamptz default now()
);

-- ===== trigger 1: outreach outcomes -> candidate-email ledger =====
create or replace function sync_prospect_email_status() returns trigger as $$
begin
  update prospect_emails pe
     set status = case
           when NEW.reply_received is true then 'replied'
           when NEW.bounced is true then 'bounced'
           when NEW.sent_date is not null
                and pe.status not in ('replied','bounced') then 'sent'
           else pe.status end,
         sent_at = coalesce(pe.sent_at,
                     case when NEW.sent_date is not null then now() end),
         updated_at = now()
   where lower(pe.email) = lower(NEW.to_email);
  return NEW;
end; $$ language plpgsql;
create trigger trg_sync_prospect_email_status
  after insert or update on outreach_log
  for each row execute function sync_prospect_email_status();

-- ===== trigger 2: exact send timestamps =====
create or replace function stamp_outreach_send_times() returns trigger as $$
begin
  if NEW.sent_date is not null and NEW.sent_at is null then NEW.sent_at := now(); end if;
  if TG_OP='UPDATE'
     and coalesce(NEW.follow_up_count,0) > coalesce(OLD.follow_up_count,0)
     then NEW.last_followup_at := now(); end if;
  return NEW;
end; $$ language plpgsql;
create trigger trg_stamp_outreach_send_times
  before insert or update on outreach_log
  for each row execute function stamp_outreach_send_times();
```

---

## 3. Import the workflows and wire up credentials

1. In n8n: **Workflows → Import from File**, one at a time, for each file in `workflows/` (`agent-1-prospect-hunter.json` through `agent-9-api-key-manager.json`). They import **inactive**, leave them that way until §6.
2. In n8n's **Credentials** section, create:
   - A **Supabase** credential pointing at your new project.
   - A **Gmail OAuth2** credential for your dedicated sending mailbox (scope: full `https://mail.google.com/` access, the system needs to read, label, and send).
   - An **HTTP Header Auth** (or your provider's native n8n credential type) for your LLM provider's API key.
3. Open each imported workflow. Every Supabase node, every Gmail node, and every LLM HTTP node will show a red "select credential" warning, click through each one and assign the credential you just created. This is tedious but one-time.
4. Tavily and AbstractAPI keys are **not** n8n credentials in this build, they live as an array literal (`TAVILY_KEYS`, `ABSTRACT_KEYS`) inside specific Code nodes, so the system can rotate between multiple free-tier keys automatically. Find those arrays (search each workflow for `TAVILY_KEYS` / `ABSTRACT_KEYS`) and replace the placeholder strings with your real keys. Yes, this means the keys live in the workflow JSON in your own n8n instance, that's fine, since only you (and anyone with access to your n8n instance) can see it; just never commit or export that version publicly.

---

## 4. Rename the webhook paths

Agent 4 and Agent 7 expose several webhooks (send/skip/edit/rewrite/approve actions, clicked from the digest emails). Webhook paths must be **globally unique per n8n instance**. The imported JSON uses generic placeholder paths (`yourbrand-send-one`, `yourbrand-followup-approval`, etc.), open each webhook node and each place that *builds* the corresponding URL (usually a Code node building the digest HTML) and replace `yourbrand-` with something unique to you. Keep it consistent across all of them.

---

## 5. Rewrite the industry-specific prompts

This is the part that actually makes the system yours. Nothing below is a secret, it's just work.

- **Agent 1**, the code node that builds your city/niche search combinations, and the prompt that defines what counts as a *qualified* company for your ICP (target size, budget tier, explicit exclusions).
- **Agent 2**, the prompt defining which roles count as decision-makers for your market, and the website-crawl "can they actually pay?" fit rubric. This is the single most industry-specific prompt in the system.
- **Agent 3**, the heart of it. The persona, your actual value proposition, your credibility line and results (your real case study, once you have one), the call-to-action. **If you change the writer prompt, change the scorer/retry/gate-rewrite prompts too, in the same pass**, they all enforce the same contract, and letting them drift out of sync is exactly what causes the zero-drafts failure mode described in [documentation.md](documentation.md) §3.
- **Agent 7**, same persona/value-prop rewrite for the three follow-up touches.
- **Agent 4**, swap the recipient email (everywhere a Gmail node sends "to you") and any branding strings in the digest.
- **Agents 5, 6, 8**, mostly industry-neutral; just make sure Agent 5's self-exclusion filter matches your actual sending address (so the reply tracker doesn't process its own outgoing notifications as "replies").

**Nothing else needs to change.** The pipeline statuses, the tables, the triggers, the pacing logic, the verification layer, all industry-neutral by design. Don't touch them just because you're customizing.

---

## 6. Go live, in order

Test **one agent at a time, manually**, before turning on the rest:

1. **Agent 1**, run it once, check `prospects` got new rows that actually look like your target customer.
2. **Agent 2**, run it, check prospects flip to `enriched` and `prospect_emails` has verified candidates.
3. **Agent 3**, run it, and **actually read 3-4 full generated emails**. Don't skip this. If they read like a template, your Agent 3 prompt needs work before anything else matters.
4. **Agent 4**, send yourself one test email first via the single-send action. Do not click Approve All on day one.
5. **Agent 5 / Agent 6**, send yourself a test reply and a test bounce (email to a nonexistent address at a domain you control) and confirm they get detected within a poll cycle.
6. **Agent 7**, after you have a few real sends 3+ days old, confirm follow-ups thread correctly into the *same* conversation and not anyone else's.

Then activate all nine, and check the **Executions** tab the next morning to confirm the schedules actually fired.

### Volume ramp

Start conservative on a fresh sending mailbox: roughly 20/day the first week, 40/day the second, growing from there only if bounce and spam-complaint signals stay clean. The random 1-3 minute pacing between sends handles spacing *within* a batch; your digest/send caps control the *daily* ceiling, raise those gradually, not in one jump.

---

## 7. Before you touch anything else

Read [documentation.md](documentation.md) §6, the engineering rules there are the accumulated cost of every real bug found building this system (default-OR filters, batch-processing footguns, alert-flood guards, and more). Every one of them will find you too if you start editing these workflows without knowing they exist.
