Cold Pitch Boss

How it works (architecture)

An honest look at every moving part of the agent.


Architecture

Cold Pitch Boss is intentionally simple. It’s six small Python modules plus a SQLite database, orchestrated by a single agent loop. There is no microservices layer, no event bus, no cloud. The whole system can be understood in twenty minutes of reading code.

cold_pitch_agent/
├── agent.py                       # Orchestrator + CLI
├── prompts/system_prompt.md       # The system prompt Claude reads
├── tools/
│   ├── icp.py                     # ICP loader + 10-attribute match scorer
│   ├── researcher.py              # 5-topic research playbook
│   ├── browser.py                 # Human-like Playwright wrapper
│   ├── composer.py                # Claude-driven pitch writer
│   ├── linter.py                  # 13-principle + spam-trigger gatekeeper
│   ├── scheduler.py               # Best-times-to-send calculator
│   └── gmail_tool.py              # Gmail API (drafts-first)
└── storage/
    ├── schema.sql                 # SQLite schema
    └── db.py                      # Persistence helpers

The flow per prospect

For each prospect in your seed list:

  1. researcher.research()
       └─ Opens a real browser. Visits Google, the company's site, their blog,
          their press page, their newsletter signup, the prospect's LinkedIn,
          and the Facebook Ads Library. Mouse moves with curved paths and
          jitter; typing has variable delay; scrolling pauses like reading.
       └─ Extracts: revenue signal, trigger events (snippets + days-old),
          industry text, culture pages, individual profile data.

  2. icp.score(icp, prospect)
       └─ Returns match_score (0..10) and unmet attributes.
       └─ If < 7/10 → skip prospect.

  3. Check trigger_days_old <= 90
       └─ If no trigger or stale → skip prospect.

  4. composer.compose(prospect, research)
       └─ Sends prompt + research to Claude. Claude returns structured JSON.
       └─ Composer parses into a PitchDraft.

  5. linter.lint_pitch(draft)
       └─ 13 principles + 60+ spam-trigger words + 6 deliverability rules.
       └─ If fails → composer rewrites up to 2x with linter feedback.
       └─ If still fails → mark prospect "needs_review".

  6. gmail_tool.create_draft(prospect, subject, body)
       └─ Saves a draft in your Gmail account.

  7. scheduler.first_pitch_at() / followup_at()
       └─ Computes Tue 10am / Thu 8am / next-Tue 1pm in prospect's timezone.

  8. db.insert_pitch() for #1, #2, #3
       └─ Pitch #1 has the actual body. #2 and #3 are placeholders;
          their bodies are composed at send-time.

  9. If --send flag was passed:
       └─ gmail_tool.send_draft(draft_id) for pitch #1.

Why each piece looks the way it does

researcher.py uses a real browser instead of HTTP requests. Because LinkedIn, Facebook, and most marketing-savvy company sites detect headless requests and feed them sanitized content (or block them outright). A real Chromium instance with human-like behavior gets the page a normal user gets.

browser.py does mouse-curve and typing-jitter. Bot-detection vendors fingerprint the speed and linearity of mouse moves and the perfectly-uniform key-press intervals of page.fill(). Our wrapper introduces a slow-in/slow-out Bezier path for the cursor and per-character delay between 50–180ms for typing, with occasional typos-and-backspace to look genuinely human. Plus a webdriver JS-property override to defeat the most common automation detector.

composer.py talks to Claude, not GPT. Claude tends to produce shorter, less salesy cold-pitch copy out of the box, and our system prompt is tuned for it. You can swap models by setting ANTHROPIC_MODEL (Sonnet, Opus, Haiku).

linter.py runs Python, not Claude, on every draft. Because asking an LLM “did you follow the rules?” is unreliable. We extract the linter checks into deterministic code so each rule produces the same result every time.

gmail_tool.py defaults to drafts. The only way to bypass this is to explicitly pass --send on the CLI. A casual user cannot accidentally blast out emails.

scheduler.py works in the prospect’s timezone, not yours. Because the empirical research on best-send-times is universal across timezones — 10am local is 10am local everywhere — and pitching when the prospect is asleep is the easiest way to land your email at the bottom of a 200-email morning queue.

Storage is SQLite, not Postgres. Because the whole system runs locally for one user. A single-file database that can be opened with any tool is the right level of complexity. If you want to graduate to a multi-user shared-pipeline scenario, swap db.py for SQLAlchemy and you’re set.

What’s not in the architecture

  • No reply-handling logic. When a prospect replies, you handle it. The agent records the open and the reply event but doesn’t auto-respond. This is intentional — replies are where the actual relationship happens, and a bot replying to a human about a freelance contract is a non-starter.
  • No analytics dashboard. Use the SQLite file with any DB tool. There are example queries in the quickstart.
  • No multi-tenant support. The agent is for one freelancer. If you’re a team, run separate instances per teammate.
  • No browser-based UI. The whole agent is a CLI tool. The dashboard, if you want one, is your Gmail drafts folder.

What you can tune

  • cold_pitch_agent/prompts/system_prompt.md — the system prompt Claude reads. Edit it to change tone, length, structure of generated pitches.
  • cold_pitch_agent/tools/linter.py — the 60+ spam-trigger words, the banned openers, the fluff phrases. Add your own.
  • cold_pitch_agent/icp_example.yaml — copy this and edit it per ICP/campaign.
  • cold_pitch_agent/tools/scheduler.py — the best-times rules. Tweak if your specific industry breaks the conventional pattern.

That’s the entire architecture. If something here feels wrong for your use case, fork it — the code is small enough that meaningful changes take an afternoon.