# Always Be Selling — Development Log

Lessons learned, reflections, and what actually moved the needle.

---

## [2026-05-21] — Rebuilt My AI Sales Outreach Pipeline From Scratch

I've been running an automated B2B outreach system for about a year. It started as a collection of Python scripts that scraped public business filings, scored leads, enriched them with search APIs, and used an LLM to draft personalized cold emails. It worked, technically. But "working" and "producing results" turned out to be very different things.

The old system had about 24 scripts, 13 library modules, a 1,800-line JSON config file, and two completely separate drafting systems that I'd forgotten were both running. One was template-based (hardcoded strings with slot-filling), one was LLM-based, and a daily cron job was using the template version while I thought I was iterating on the LLM version. I only discovered this during the rebuild.

Reply rate on the old system was roughly 1-2%. The emails looked fine if you read one. If you read thirty in a row, you could see they were all the same email with different names filled in.

Here's what I learned rebuilding it:

### Architecture matters less than what data reaches the LLM

My old system collected about 30 signals per lead but only injected 8 into the drafting prompt. Equipment context, business descriptions from search APIs, competitor presence — all collected, none used. The single biggest improvement came from actually passing that data through to the model. The fancy scoring system, the persona routing, the template selection logic — none of that moved the needle the way "give the LLM more real information about the specific business" did.

### Reasoning models silently broke my pipeline

I swapped to a reasoning model and started getting empty drafts. Turned out the model was returning its output in a reasoning field instead of content, and my parser only read content. I had phantom records in my CRM — leads that looked drafted but had empty emails. The fix was reading both fields and hard-failing on genuinely empty responses. But the real lesson was: swap models deliberately and verify one output before running a batch.

### The LLM will confidently sell products you don't offer

My product is physical equipment. The model kept drifting into selling "document management solutions" and "workflow optimization" — things that sound like what I sell but are actually different product categories. I had to add a hard constraint: "every problem you describe must be solved by a physical machine in their office. If the sentence still makes sense without the equipment involved, cut it." That one instruction fixed an entire class of bad output.

### Subject lines were the weakest link and I didn't know it

24 out of 30 drafts had subject lines following the pattern "For [Business Name] in [City]." That's not a subject line, it's an address label. The model was doing exactly what the prompt said — "use their name, city, or business type" — which turned out to be a slot-filling instruction, not a quality instruction. Replacing it with "the subject must reference one specific detail about this business's situation, and could not have been sent to any other business in the same industry and city" produced immediately better results.

### Principles beat branches for prompt design

My first instinct was to build scenario-specific prompts — one for new businesses, one for established ones, different framings for different milestones. An advisor talked me out of it. Instead, the temporal data (when the business was founded, how old they are) goes into the lead profile and one principle says "let the business's age inform your framing naturally." The model reads "founded 5 years ago" and writes a different email than it does for "founded last week" — without any routing logic or prompt selection. Same prompt, different output, driven by data not code.

### "Modular prompts" doesn't mean what I thought

I wanted to be able to tweak one aspect of the email without rewriting the whole prompt. But breaking it into per-scenario prompt files just created the same problem in more files. What actually works is a single prompt with a small set of universal principles (stay on-product, ground claims in evidence, match energy to the lead's situation) where the variation comes from what the model knows about each specific lead. The prompt is the constant; the lead data is the variable.

### The result

The new system has about a third of the files, no dead code, atomic state operations with file locking, a clean CLI, and the ability to A/B test prompt variants by creating a file rather than editing a function. But honestly, the architecture is maybe 20% of why the output is better. The other 80% is just giving the model better inputs and more specific constraints.

The old system produced emails that read like a marketing automation platform wrote them. The new one produces emails that read like they came from a person who actually sells equipment for a living. That's the whole difference, and most of it came from prompt work, not code work.

---

### Generation Comparison

**Generation 1 (template-era):**
- Subject: "Managing discovery printing before your five-year"
- Opens with "Noticed your firm's upcoming five-year mark"
- Body sells "streamlining high-volume, confidential print jobs"
- Closes with "A brief call to discuss your current workflow?"
- Em dashes throughout
- Missing phone number on some signatures
- Reads like: a marketing automation platform

**Generation 2 (first LLM rewrite):**
- Subject: "Managing discovery print volume for complex filings"
- Body names the actual equipment ("your printer is a critical piece of equipment")
- Mentions specific capabilities (double-sided printing, scan-to-email)
- Closes with "I can outline what that looks like specifically for a litigation practice"
- Em dashes eliminated, phone number consistent
- Reads like: a better template

**Generation 3 (principles-based, current):**
- Subject: "Printing filings and discovery for a 5-year practice"
- Opens with the recipient's situation, not the sender's observation
- Body stays on-product (print and scan setups, not "document workflows")
- Closes with one specific fact the recipient probably doesn't know: "current machines handle one-touch scan-to-email and scan-to-folder, removing the old multi-step process for discovery"
- No ask, no meeting request, no "I can outline" — just the insight, then signature
- Reads like: a person who sells equipment for a living

### What changed between each generation

**1→2:** The LLM replaced the template. Output got more specific but still sounded like a vendor pitch with hard-drop closes ("let me outline," "let's discuss"). The product anchor was loose — emails drifted into selling consulting and workflow software instead of physical equipment.

**2→3:** The prompt shifted from scenario branches (eight different framings selected by code) to universal principles (product anchor, evidence grounding, age-aware framing from data). Closes shifted from asking for meetings to offering one useful fact. Subject lines shifted from slot-filling ("For [Name] in [City]") to referencing the recipient's specific situation.

**The pattern:** Each generation improved not by adding complexity but by removing it. Gen 1 had template routing and keyword matching. Gen 2 had scenario dictionaries and branching logic. Gen 3 has principles and data. Less code, better output.

---

## [2026-05-21] — Context Assembly Bug: Greeting Name from Brave Search

### Issue

Lead #20 "2 Boston Post Mbfh LLC" was greeted as "Robert" in the draft body. Where did Robert come from?

### Root Cause

1. Brave search for the entity returned a city-data.com page mentioning "ROBERT A. COSTANZO" with a Branford address
2. `brave_summary` field stored this raw text:
   ```
   "Name: ROBERT A. COSTANZO Street Address: 343 BEACH STREET, WEST HAVEN, CT, 06516, United States Residence Address: 200 LAUREL HILL ROAD, BRANFORD, CT, 06405, United States"
   ```
3. Drafting prompt injected `brave_summary` as "Business description:"
4. LLM extracted "Robert" for the greeting — but Robert has no confirmed connection to this LLC

### Evidence

- LLC address: **15 Hosley Ave, Branford**
- Robert's address: **200 Laurel Hill Rd, Branford**
- Both in Branford, but **different addresses** — no verified principal relationship

### Classification

**Context assembly issue**, not LLM hallucination. The data was in the prompt; it just wasn't validated.

### Fix Options

| Option | Where | What |
|--------|-------|------|
| A | `drafting.py` | Prompt guardrail: "Never extract a greeting name from brave_summary. Only use a name if explicitly provided as principal/owner." |
| B | `brave.py` / enrichment | Add `greeting_name` field, populated only when high confidence (CT SOS agent_name, or parsed "Principal:" / "Owner:" patterns). Use that for greetings. |

**Recommendation:** Option B is more robust — prompt shouldn't have to guess. But Option A is a quick band-aid.

### Files Involved

- `integrations/brave.py` — `brave_enrich_lead()` populates `brave_summary` from first search result description
- `capabilities/drafting.py` — `_build_prompt()` injects `brave_summary` as context
- `capabilities/drafting.py` — LLM extracts greeting from whatever context is available

---

## [2026-05-21] — Daily Debrief

### What We Built

Three phases shipped in one session.

#### Phase 3: Outside Sales Export (Brian's List)

- **New file:** `cli/export_outside_sales.py`
- **New CLI:** `absel export outside-sales`
- County-grouped printable lists of leads with no digital contact path
- Qualification: no usable email AND no phone AND has physical address
- Filing-service phones stripped from consideration (user directive)
- Plain ASCII output — safe for Word paste and laser printer
- County order: New Haven → Fairfield → Hartford → alphabetical → UNKNOWN
- CSV format with county as first column
- `--limit` flag for quick queries ("show me 25 New Haven leads")
- Result: 6,765 leads currently qualify (mostly un-enriched, so all in UNKNOWN county — will redistribute as Brave enrichment populates county data)

#### Phase 1: Agent & Principal Integration

- **Modified:** `integrations/ctsos.py`, `state/schema.py`, `capabilities/enrichment.py`, `capabilities/drafting.py`
- Bulk fetch functions with `$where=key IN (...)` — batch size 200
- Agent type classification: `individual`, `business`, `secretary_of_state`
- SoS-as-agent detection (keyword match on name)
- Principal data fetched during enrichment, not harvest
- `enrich_batch()` pre-fetches all agent/principal data in bulk, then distributes to individual leads
- `_determine_greeting()` priority chain updated:
  1. Principal name (person-like only)
  2. Agent name (individual type only)
  3. Business name extraction
  4. Short company name
  5. None
- **New schema fields:** `agent_type`, `principals`, `principal_names`, `greeting_name_source`, `principal_competitor_overlap`, `principal_competitor_detail`, `address_competitor_overlap`, `address_competitor_detail`, `deal_outcome`, `deal_closed_at`, `business_id`
- `business_id` now stored on every lead at harvest time (join key for Socrata)

#### Phase 2: Competitive Intelligence

- **New file:** `capabilities/competitive_intel.py`
- **New CLI:** `absel intel agents`, `absel intel principals`, `absel intel addresses`, `absel intel wins`
- **Agent displacement report** — groups by agent, calculates competitor displacement rate vs baseline, flags HIGH at >1.5x
- **Principal overlap** — cross-references principals of competitor-flagged leads against all other leads, writes `principal_competitor_overlap` flag
- **Address clustering** — normalizes addresses, finds multi-business locations with competitor presence, writes `address_competitor_overlap` flag
- **Win-rate stub** — placeholder for future Zoho integration
- Address normalization: lowercase, strip punctuation, expand 20 common abbreviations
- Queries 2 and 3 explicitly announce they're writing flags back to leads

### Testing

- All 49 tests passing
- Fixed 5 drafting test failures (mock responses were missing phone number in signature — validation now requires `203-777-0011`)
- All new commands verified working:
  - `absel export outside-sales` — 6,765 leads, plain text
  - `absel export outside-sales -l 25 -c "New Haven"` — limited, filtered
  - `absel export outside-sales -f csv` — valid CSV output
  - `absel intel agents` — runs, no agents with 10+ filings yet (enrichment needed)
  - `absel intel principals` — runs, no overlaps found yet
  - `absel intel addresses` — found 3+ address clusters with competitor presence in existing data
  - `absel intel wins` — stub prints correctly

### Key Decisions Made

- Batch size 200 for Socrata bulk queries
- Principal matching: case-insensitive exact only — no fuzzy matching
- Any phone excludes from outside sales — even filing-service phones
- Outside sales export before agent integration — zero dependencies, immediate value
- Competitive intel queries write flags explicitly — operator sees "Updates X flags on Y leads"

### What's Next

- Agent/principal data is not yet populated on existing leads — need to run enrichment with the new bulk fetch
- County data is missing on most leads — need Brave enrichment to populate
- Once enriched, the outside sales list will distribute across counties properly
- Competitive intel will produce meaningful reports once agent/principal data is on leads

---

## Template

```markdown
## [YYYY-MM-DD] — Title

What happened, what you learned, what changed.

### Details

Specific technical details if relevant.

### Files Changed

- `path/to/file.py` — what changed

### Impact

What this means for the system.
```