Skip to content

One box, on autopilot, with a funnel to watch it - #31

Merged
ralyodio merged 1 commit into
mainfrom
feat/autopilot
Aug 16, 2026
Merged

ralyodio merged 1 commit into
mainfrom
feat/autopilot

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What was wrong

Four things were true of OutreachGraph before this:

  1. The front door asked for a GitHub handle — a strange thing to ask a salesperson to start a sales campaign with.
  2. There was no way to describe a market. You could paste URLs, which assumes you already know every company you want.
  3. Nothing it wrote could ever be sent. POST /actions/:id/execute recorded that a send had happened elsewhere; it never sent anything. Every run ended at a card a human had to copy out by hand.
  4. There was no view of where any lead stood.

What this does

One front door. POST /api/v1/campaigns takes one line and classifies it deterministically. A domain is crawled; a phrase goes to a new discover_domains job that asks the model for real companies and queues a crawl each. Nothing the model says is trusted — every domain must survive being fetched, so invented companies drop out on their own. The GitHub intake is removed from the UI; GitHub stays a signal source, per the non-negotiable.

Autopilot. runAutopilot is the executor that actually puts mail on the wire. It rides the existing trusted_automation approval mode, so no non-negotiable is bent:

  • policy is re-checked from live state, never from the recommendation's snapshot
  • trusted_automation stays opt-in per campaign, and isExecutable(decision, false) means only a plain allow sends
  • a draft that failed its quality gates is never machine-sent
  • a daily cap, a per-prospect cooldown, and a give-up bound after 3 failed sends
  • reply-to points at the customer, so replies — the one thing a human does — reach them

Three gaps found by running it, not reading it. Each independently made the whole thing produce nothing:

Gap Effect Fix
No address was ever stored Nothing to send to Site provider reads mailto:/prose, separates personal from role addresses
Only GitHub made signals, and outbound needs a trigger Every crawled person stopped at "scored" A published team page stating a role is stored as grounded evidence
Crawled people had identity confidence 0 vs a 0.85 threshold Policy denied every send A company naming its own staff is first-party evidence, recorded as an identity

Notifications. An instant lead alert (one per person, ever) and a daily digest — sent even on a quiet day, because silence is indistinguishable from a broken product. Both idempotent via a unique index.

The funnel. lead_stage_events records every transition; the 16 engineering states collapse to 6 stages a salesperson recognises. /funnel shows stage bars with conversion and a sideways per-lead timeline where width is time spent, so a stall is visibly wide.

Verification

bun run check — format, typecheck, 624 tests pass, 0 fail (61 new).

Run live against real sites: "dental practices in Austin Texas" → 25 real practices discovered → crawled → named dentists found at confidence 0.9 → published inboxes captured → grounded drafts written ("Hi Dr. Mulgrew, I saw 'Dr. Blake Mulgrew — Dentist' listed for Circle C Dental…") → sends attempted, failing only on a deliberately invalid Resend key. Nothing was delivered to anyone.

Note before merging

Migrations apply at boot on merge to main. 0009_autopilot.sql adds workspace_settings, notifications, lead_stage_events, and columns on campaigns/companies.

Autopilot only sends where a mailer is configured; RESEND_API_KEY/EMAIL_FROM are already set in Railway. Campaigns created with autopilot: true will send real email to real people once merged — the daily cap (default 25) and the per-campaign toggle are the controls.

🤖 Generated with Claude Code

Four things were true of this product before this change: the front door
asked for a GitHub handle, there was no way to describe a market, nothing
it ever wrote could actually be sent, and there was no view of where any
lead stood. This addresses all four.

**The front door.** `POST /api/v1/campaigns` takes one line of text and
works out what it got. `acme.com` is crawled directly; "dental practices
in Austin" goes to a new `discover_domains` job, which asks the model for
real companies and queues a crawl for each. Nothing the model names is
trusted — every domain has to survive being fetched, so an invented
company simply drops out. The GitHub-handle form is gone from the UI.
GitHub remains a signal source, which is where it belonged.

**Sending.** `POST /actions/:id/execute` only ever recorded that a send
had happened somewhere else; nothing left the building. `runAutopilot`
is the executor. It re-checks policy from live state rather than from the
snapshot on the recommendation, holds to `trusted_automation` being
opt-in per campaign, refuses a draft that failed its quality gates, obeys
a daily cap, and sets reply-to so answers reach the customer — the one
part a human still does.

Three gaps had to close before any of that could work, each found by
running it against real sites rather than by reading the code:

  - Nothing anywhere stored an address. The site provider now reads
    `mailto:` links and prose, tells a personal address from a role one,
    and keeps them apart: a shared inbox belongs to the company and is
    marked as such at send time.
  - The recommendation engine refuses an outbound action with no trigger
    signal, and only GitHub produced signals — so everyone found by
    crawling reached "scored" and stopped. A published team page stating
    someone's role is grounded, citable evidence, and is now stored.
  - Crawled people had identity confidence 0 against a 0.85 threshold,
    so policy denied every send. A company naming its own staff on its
    own site is first-party evidence and is now recorded as an identity.

**Notifications.** A lead alert the moment someone qualifies, one per
person, and a daily digest — sent even on a quiet day, because silence is
indistinguishable from the product being broken, which is the failure
this whole change exists to fix. Both are idempotent through a unique
index rather than through careful bookkeeping.

**The funnel.** `campaign_people.status` keeps only the present, so it
could answer where a lead is and nothing else. `lead_stage_events` records
every transition, and the sixteen engineering states collapse into six
stages a salesperson recognises. `/funnel` draws the stage bars and a
sideways per-lead timeline where width is time spent, so a stall is
visibly wide.

Verified end to end against real sites: "dental practices in Austin"
produced 25 real practices, crawled them, found named dentists, captured
their published inboxes, wrote grounded drafts, and attempted sends that
failed only on a deliberately invalid API key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio marked this pull request as ready for review August 16, 2026 09:41
@ralyodio
ralyodio merged commit 9856348 into main Aug 16, 2026
4 checks passed
@ralyodio
ralyodio deleted the feat/autopilot branch August 16, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant