Projects / Client work

B2B Outreach Engine

I built a prospect-intelligence platform for Afri Investment PLC, a pumps, plumbing and mining group in Ethiopia, through my consulting agency Renvane. It has a FastAPI service, a Supabase Postgres database and a React dashboard. Research and drafting workflows feed it, and a person approves every outreach message before it goes out.

Problem

Afri wanted buyers across East and Central Africa and had a small business-development team. Writing credible first-contact messages company by company, by hand, did not scale. Mail-merge outreach would hurt the company’s name in markets it was trying to enter, and bought lead lists carry no information about a prospect’s actual projects.

The constraints: the research had to be citable, the tool had to be usable by non-technical staff, and nobody at the client would operate infrastructure after handover.

My role

I designed and built the platform alone. Every commit in the repository is mine, from the first commit on 2026-03-27 onward. That covers the schema, the FastAPI backend, the React dashboard, the Supabase Auth wiring and the Vercel and Railway deploy config.

I also operated the research and outreach workflows: using scheduled tasks with the current ChatGPT model fleet for research and drafting, vetting candidates, and loading them through the API. Renvane was the contracting agency. The client’s CEO reviews outreach copy. Afri’s team supplied the company context the drafts draw on.

How it works

Research comes in as reports, becomes structured records, and drafts are written from those records.

Scheduled research tasks (one report set per market)
        |  candidates + source links
        v
Human vetting + cross-market dedupe
        |  POST /companies, /contacts, /research
        v
FastAPI (7 routers, 6 services, Pydantic) --- JWT check ---> Supabase Auth
        |  supabase-py
        v
Supabase Postgres + pgvector
 subsidiaries -> companies -> contacts
 research_entries (append-only, source_urls)   internal_kb
        |                          |
        v                          v
React dashboard (dossier pages)   Drafting workflow
                                       |
                                       v
                              Human review gate -> send

The API has 19 routes. Each router parses a request, calls one service module and maps the result to a status code. The research stage and the drafting stage are operated workflows, not code in the repository. They write through the same REST endpoints the dashboard uses.

Key decisions

Platform skeleton first, agents later. I built the schema, API, auth and dashboard before any coded agent. The alternative was an agent-first prototype writing to spreadsheets. Agent tooling changes often and data contracts should not, so I fixed the contracts and ran the research as operated workflows that call the same endpoints. Wiring in coded agents later changes who calls the API, not the API.

Append-only research log with sources. Research notes are inserted into research_entries with source_urls and are never updated. The service module exposes no update or delete. The alternative was one editable profile per company. Outreach honesty depends on knowing what was learned, when and from where, and every message can be checked against a source.

Subsidiaries as database rows. The business lines (pumps, mining, food fortification) are rows in a subsidiaries table with a domain_context column. The dashboard sidebar is generated from that table. The alternative was three hardcoded views. Adding a business line is an insert, and domain_context is the field future agent prompts would be built from.

Validate tokens against Supabase Auth on every request. get_current_user passes the bearer token to Supabase and returns a 401 on any failure. The alternative was decoding JWTs locally. Local decoding is faster but trusts a token until it expires. This is an internal tool with a handful of users, so one network hop per request is cheap.

What was hard

Uneven data across markets. Some countries have rich procurement reporting and others have almost none. The rule was to record only what could be cited and leave the rest empty. Thin markets produce small records, not padded ones.

Duplicate companies across reports. The same large contractors and utilities appear in several country reports. I wrote a cascade-aware dedupe script that keeps the lowest-id row for each company name and deletes the duplicates along with their contacts and research entries. It matches on exact name only.

No fabrication at volume. Drafts may only use facts from a prospect’s research entries. This is enforced by process, not code. The sources let a reviewer verify each claim, and a person reads every message.

Results

Verified from the repository:

  • 19 API routes, 6 service modules, 5 core tables.
  • 22 pytest router tests against a mocked Supabase client.

I have not measured outreach outcomes. As of my last notes in May 2026 no messages had been sent, so reply rates and meetings booked do not exist yet. I have not measured time saved either.

Limitations and next steps

  • The jobs endpoints (POST /jobs/research) are a stub. They keep jobs in memory and execute nothing. There is no queue.
  • pgvector columns exist but no embeddings are generated, so there is no semantic search.
  • Role tiers are not built, so access is the same for every signed-in user.
  • The no-fabrication rule and the review gate are procedural. The code does not stop an unreviewed draft from being used.
  • Next: coded agents on the existing endpoints, send tracking, and measured reply rates.