← backThe problem
Outbound sales is hours of manual research per prospect: finding the right people, reading what they have posted, working out what they actually care about, then writing an email that does not sound automated. Most tooling automates the sending and skips the thinking. The tools that do score prospects hand you a number with nothing behind it, so nobody trusts it, and an agent that can send email on your behalf is one bad generation away from embarrassing you in front of a customer.
What I built
A multi-tenant AI sales development agent that finds prospects, researches them, and drafts personalised outreach, where every prospect score cites a source URL, a snippet and an observed-at timestamp, so a human can check the evidence behind any number. Sending is approval-first: nothing leaves the system until a person approves it, and a policy guard makes duplicate sends impossible.
Architecture
Next.js · dashboard, prospects, email editor, approvals queue
↓
FastAPI · 68 endpoints across 17 routers
├── LangGraph multi-stage research → signal extraction → draft
├── OpenAI LLM inference
├── Pinecone per-workspace namespaces, vector memory
├── Python deterministic scorer, 6 dimensions summing to 100
└── Braintrust tracing over the whole pipeline
↓
Supabase · Postgres + auth · 40 RLS policies over 45 tables
Redis · cache & queues
↓
Vercel (frontend) · Railway (backend)Highlights
- Every prospect score cites its evidence (source URL, snippet and observed-at timestamp), so a number can always be audited back to what the agent actually saw.
- Drafting context is restricted to a human-approved claims table, so unverified personalisation is unreachable by construction rather than discouraged by a prompt.
- Research runs over web search plus Pinecone retrieval, and scoring is explainable enough that a rep can see exactly why a lead ranked where it did.
- Scoring splits into an LLM signal-extraction stage and a deterministic Python scorer across 6 weighted dimensions summing to 100, so identical inputs produce byte-identical, unit-testable scores.
- Approval-first send pipeline (pending → approved → sent) with a six-rule policy guard, and a unique idempotency key that makes duplicate sends impossible.
- Multi-tenant isolation enforced by 40 row-level-security policies across 45 Postgres tables, with 0 tables left unprotected.
- A 12-intent reply classifier whose 1.0 unsubscribe recall is enforced as a CI gate, so a release cannot ship if it would miss an opt-out.
- A 103-case evaluation suite over the pipeline, so changes to prompts or scoring are measured rather than eyeballed.
- Per-workspace Pinecone namespaces, so one tenant's vector memory can never surface in another tenant's retrieval.
- Braintrust tracing across the pipeline, so every stage of a run is inspectable after the fact, not guessed at from logs.
Engineering decisions
Split scoring into LLM signal extraction plus a deterministic Python scorer
instead of Asking the model for the score directly
A number straight out of an LLM is not reproducible, not unit-testable, and cannot be explained to the salesperson relying on it. Letting the model do only what it is good at, pulling signals out of messy text, and handing the arithmetic to 6 weighted Python dimensions summing to 100, means identical inputs always give byte-identical scores, the weights are visible and tunable, and the scorer has real tests.
Approval-first sending with a unique idempotency key
instead of Letting the agent send autonomously
An agent with unsupervised send access is one bad generation away from damaging a real customer relationship, and a retry after a timeout is one duplicate away from doing it twice. An explicit pending → approved → sent state machine keeps a human in the loop, and a unique key on the send makes duplicates impossible at the database level rather than 'unlikely' at the application level.
Row-level security in Postgres for tenant isolation
instead of Filtering by tenant ID in application code
With 45 tables, application-level filtering means every future query is a chance to leak another organisation's pipeline, the worst possible bug in a sales tool. Pushing isolation into RLS policies makes the database refuse cross-tenant reads regardless of what the application asks for, so correctness does not depend on remembering a WHERE clause.
Evidence attached to every score, not just a number
instead of Surfacing a bare confidence score
Salespeople ignore scores they cannot interrogate. Storing the URL, the snippet and the observed-at timestamp behind each signal makes the score checkable, makes stale research visible, and turns a debugging session from 'why did it say 82?' into reading the three sources it actually used.
Unsubscribe recall gated in CI at 1.0
instead of Tracking classifier accuracy on a dashboard
Missing an opt-out is not a quality regression, it is a compliance failure and a person who asked to be left alone being contacted again. Of the 12 intents the reply classifier handles, that is the one where a single miss is unacceptable, so the release fails rather than the metric dipping quietly on a chart nobody opens.
Draft only from a human-approved claims table
instead of Telling the model in the prompt not to invent things
A prompt instruction is a request, and the one time it is ignored, a false claim about a prospect's company goes out over a real person's name. Restricting the drafting context to claims a human has approved makes unverified personalisation unreachable by construction: the model cannot state something it was never given.
Stack
- Next.js
- React
- TypeScript
- FastAPI
- LangGraph
- OpenAI
- Supabase
- Postgres / RLS
- Pinecone
- Redis
- Braintrust
- Vercel
- Railway