AI inbound lead qualification, scoring and routing engine — speed-to-lead automation for agencies, brokerages and B2B service firms.
▶ Live interactive demo · API reference · Deployment guide
Agencies, real-estate brokerages and B2B service firms get inbound leads from web forms, WhatsApp and portals — and then lose them two ways:
- Reps waste hours on junk. SEO spam, dev-shop pitches, tyre-kickers and duplicate submissions all land in the same inbox as a AED 18m penthouse buyer.
- They reply too slowly. Speed-to-lead is the single biggest driver of inbound conversion. A lead answered in five minutes converts at a multiple of one answered the next morning — and by then a competitor has usually replied first.
leadflow·ai closes both gaps in one synchronous request: it ingests every lead, enriches it, scores it against a transparent rule engine, writes a personalised first reply, and routes it to the right owner with an SLA timer running — typically in single-digit milliseconds, well inside a sub-60-second speed-to-lead target end to end.
| Buyer | What they get |
|---|---|
| Real-estate brokerages (Dubai, GCC, UK) | Portal and WhatsApp enquiries triaged by budget and timeline, prime-property buyers on a 5-minute SLA, time-wasters into a nurture sequence |
| Agencies (marketing, design, dev) | Outbound pitches auto-quarantined; real briefs routed by budget band with a drafted reply already written |
| B2B service firms & SaaS | BANT scoring on every form fill, enterprise deals to the named AE, everything else to lifecycle marketing |
| Anyone running paid inbound | A defensible answer to "which of these leads is worth a rep's hour?" — with the reasoning shown, not hidden in a black box |
- Speed-to-lead under 60 seconds, every lead, every channel — no rep required in the loop.
- Reps stop touching junk. In the bundled 26-lead sample, 15% is quarantined before a rep sees it, and a further 12% is diverted into an automated nurture sequence rather than a call.
- Every score is explainable. "Score 88" is useless; "Score 88: +25 budget, +20 timeline, +12 authority, +14 need, +7 channel, +20 enrichment" is something a sales director will trust.
flowchart LR
subgraph Inbound
A1[Web form]:::src --> API
A2[WhatsApp]:::src --> API
A3[Portal / Bayut]:::src --> API
A4[CSV bulk import]:::src --> API
end
API[["POST /api/leads<br/>FastAPI"]]:::api --> ENR
subgraph Engine["Pipeline — one synchronous request"]
direction TB
ENR[Enrichment<br/><i>offline dataset</i>]:::box --> DED[Dedupe<br/><i>email · phone · alias</i>]:::box
DED --> SCO[Rule engine<br/><b>0-100 + breakdown</b>]:::core
SCO --> LLM[LLM pass<br/><i>intent · urgency · objections</i>]:::llm
LLM --> REP[Reply drafting<br/><i>lead's own language</i>]:::llm
REP --> ROU[Routing rules<br/><i>config/routing.yaml</i>]:::box
end
ROU --> DB[(SQLite / Postgres)]:::store
ROU --> WH[Slack + generic<br/>webhooks]:::out
DB --> DASH[Operator dashboard]:::out
DB --> DOCS[OpenAPI docs]:::out
LLMC{{"LLMClient"}}:::llm -.-> LLM
MOCK[MockClient<br/><b>zero-key default</b>]:::core -.-> LLMC
ANTH[AnthropicClient<br/>claude-sonnet-5]:::llm -.-> LLMC
classDef src fill:#151d2c,stroke:#2c3a52,color:#eaf0f9
classDef api fill:#1c2f52,stroke:#4f8cff,color:#eaf0f9
classDef box fill:#101724,stroke:#2c3a52,color:#eaf0f9
classDef core fill:#123a2c,stroke:#2fd493,color:#eaf0f9
classDef llm fill:#2a1f45,stroke:#8a5cff,color:#eaf0f9
classDef store fill:#101724,stroke:#8896ad,color:#eaf0f9
classDef out fill:#3a2410,stroke:#ffb020,color:#eaf0f9
The load-bearing design decision: the rule score, enrichment, dedupe and routing are fully deterministic and offline. They never call a third party. The LLM adds judgement on top — intent, objections, a human-sounding reply — but it is never a single point of failure. If Anthropic is down, or you have no key at all, leads are still scored, still routed, still answered from a template, and still hit their SLA.
git clone https://github.com/wasay-09/leadflow-ai.git
cd leadflow-ai
uv venv --python 3.13
uv pip install -e ".[dev]"
python scripts/seed.py --reset # 26 realistic fixture leads
uvicorn app.main:app --reloadThen open:
| URL | What it is |
|---|---|
| http://127.0.0.1:8000 | Operator dashboard — leads, filters, score breakdown, SLA view |
| http://127.0.0.1:8000/docs | Auto-generated OpenAPI / Swagger UI |
| http://127.0.0.1:8000/redoc | ReDoc reference |
No API key is needed. DEMO_MODE=true is the default: the LLM layer uses a deterministic
keyword-driven client and outbound webhooks are recorded to a table instead of being sent.
Post a lead:
curl -s -X POST http://127.0.0.1:8000/api/leads \
-H 'content-type: application/json' \
-d '{
"name": "Layla Al Mansoori",
"email": "layla@emaar.ae",
"company": "Emaar Properties",
"message": "We need to purchase 6 penthouses in Downtown Dubai immediately. Board has approved the budget. Please book a viewing this week. I am Head of Corporate Real Estate.",
"channel": "whatsapp",
"country_code": "AE",
"budget": 18000000,
"currency": "AED",
"timeline": "immediately"
}' | python -m json.toolRun the tests:
pytest # 101 tests, no API key, no network
ruff check .Six weighted rules whose maximum points sum to exactly 100, so the score reads directly as "percentage of the ideal lead". Each rule returns points and a human-readable reason — that reason is what the dashboard renders.
| Signal | Max | What it measures |
|---|---|---|
| Budget | 25 | Stated budget, normalised to USD via a bundled FX table. Seven bands from "token budget" (3) to "enterprise-scale" (25). A message that discusses budget without a figure still earns 5. |
| Timeline | 20 | Purchase urgency, from immediately (20) down to just looking (2). The most urgent matching phrase wins. |
| Authority | 12 | Job-title seniority anywhere in the enquiry: founder/CEO/owner (12) → C-suite/VP/head-of (10) → manager (7) → IC (4) → intern (2). Corporate contact with no title = 5. |
| Need | 15 | Stacked intent categories — buying intent (6), problem stated (4), scale signal (3), competitor comparison (2), plus 2 for a detailed message. |
| Channel | 8 | How hard the lead worked to reach you: phone (8) > WhatsApp (7) > web form (6) > email (5) > portal (4) > bulk import (2). |
| Enrichment | 20 | Corporate email domain (8) + ideal-customer-profile industry (6) + company size band (up to 6). |
Then penalties are applied separately, so the UI can show a strong positive score and the reasons it was pulled down:
| Penalty | Points | Trigger |
|---|---|---|
| Spam markers | −45 | SEO/backlink/crypto/dev-shop pitch phrases |
| Disposable email | −30 | Mailinator, Guerrilla Mail and friends |
| No contact details | −25 | Neither email nor phone |
| Invalid email | −20 | Structurally malformed address |
| Link spam | −15 | Two or more URLs in the body |
| No marketing consent | −10 | Consent flag explicitly false |
The final score is clamped to 0–100. The whole thing runs with zero API keys.
On top of the rule score, an LLMClient returns an intent summary, an urgency band,
a list of objections the lead raised, a verdict (qualified / nurture /
unqualified / junk) and a confidence. It then drafts a first-touch reply in the lead's
own language.
Two implementations satisfy the same protocol:
MockClient(default) — deterministic and keyword-driven. Not a stub: it detects intent and objections, and templates a genuinely personalised reply across 12 languages using script and phrase detection. Because it is deterministic, it is also what the test suite asserts against.AnthropicClient— real Claude calls viaclaude-sonnet-5, using forced tool use so the response is a validated JSON object rather than prose to be regexed. Retries transport and shape failures with exponential backoff, handles therefusalstop reason, and degrades to the mock rather than dropping a lead.
Base path /api. Full interactive spec at /docs.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/leads |
Ingest one lead. Runs the full pipeline synchronously and returns score, breakdown, enrichment, qualification, drafted reply and routing decision. 201 |
POST |
/api/leads/import |
Bulk CSV import (multipart/form-data, field file). Lenient header matching; unusable rows are reported, not fatal. 201 |
GET |
/api/leads |
List leads. Filters: priority, status, channel, owner, min_score, max_score, sla_breached, search, limit, offset |
GET |
/api/leads/{id} |
One lead in full. 404 if unknown |
GET |
/api/leads/sla-breaches |
Escalation view — leads past their SLA with no first response |
POST |
/api/leads/{id}/respond |
Stamp first response; stops the SLA clock and moves status to contacted |
GET |
/api/analytics |
Volume, average/median score, priority split, funnel, SLA breaches, leads/day, top industries |
GET |
/api/webhooks |
Outbound webhook delivery log. Filter with lead_id |
GET |
/api/score-preview |
Score a hypothetical lead without persisting it — powers the demo UI |
GET |
/api/health |
Liveness, operating mode, active LLM provider, lead count |
GET |
/ |
Operator dashboard |
Every setting is environment-driven with a working default — see .env.example.
| Variable | Default | Purpose |
|---|---|---|
DEMO_MODE |
true |
Zero-key mode. Uses MockClient and records webhooks instead of sending them |
ANTHROPIC_API_KEY |
(empty) | Only read when DEMO_MODE=false. Absent ⇒ silently falls back to the mock |
LLM_MODEL |
claude-sonnet-5 |
Model id for the real client |
LLM_MAX_TOKENS |
2048 |
Output ceiling per LLM call |
LLM_MAX_RETRIES |
2 |
Extra attempts before falling back to the mock |
DATABASE_URL |
sqlite:///./leadflow.db |
Any SQLAlchemy URL — swap for Postgres in production |
ROUTING_CONFIG |
config/routing.yaml |
Path to the routing rules; re-read automatically when the file changes |
SLACK_WEBHOOK_URL |
(empty) | Slack incoming webhook. Ignored in demo mode |
GENERIC_WEBHOOK_URL |
(empty) | Any JSON endpoint (Zapier, Make, your CRM). Ignored in demo mode |
LOG_LEVEL |
INFO |
Standard logging level |
Routing lives in config/routing.yaml, not in Python — a client can
re-route their own leads without a deploy. Rules are evaluated top to bottom, first match
wins, with a condition-free default at the end.
- name: gulf_prime_property
description: High-value Gulf real-estate buyers — the flagship revenue line.
when:
min_score: 70
industry_in: [real_estate]
region_in: [Middle East]
then:
priority: Hot
owner: layla.haddad
team: Prime Residential (Dubai)
sla_minutes: 5
tags: [hot, prime-property, gcc, call-first]Supported conditions: min_score, max_score, verdict_in, urgency_in, channel_in,
industry_in, region_in, country_in, min_budget_usd, is_corporate, is_duplicate.
An unknown key fails its rule rather than silently matching everything, so a typo falls
through to the next rule instead of hijacking every lead.
app/
config.py settings (DEMO_MODE lives here)
models.py SQLModel tables: Lead, WebhookDelivery
schemas.py Pydantic v2 request/response contracts
enrichment.py offline enrichment + dedupe keys
analytics.py dashboard aggregations
data/ bundled offline dataset + 26 fixture leads
scoring/
rules.py the six rules + penalty detectors
engine.py runs rules, applies penalties, clamps 0-100
llm/
base.py LLMClient protocol + normalisation
mock.py deterministic zero-key client
anthropic_client.py Claude via forced tool use, retry + fallback
prompts.py prompts and tool schemas
routing/engine.py YAML rule evaluation
services/ pipeline, CSV import, webhooks
api/routes.py the HTTP surface
web/dashboard.html operator dashboard
config/routing.yaml editable routing rules
docs/index.html the standalone client-side demo (GitHub Pages)
scripts/seed.py fixture loader / demo-data exporter
tests/ 101 tests
A typical engagement runs like this.
1 — Discovery (½ day). Pull a month of their real inbound. Establish what a good lead looks like for them and what "responded" means today. This is where the SLA numbers and the priority bands come from — not from a template.
2 — Tune the rule engine (1 day). The six rules and their weights are the starting point,
not the answer. A brokerage weights budget and timeline heavily; an agency weights need and
authority. Keyword banks in app/scoring/rules.py get their vocabulary. Nothing here needs
a model — it is transparent and arguable, which is exactly why sales directors sign off on it.
3 — Wire the routing (½ day). config/routing.yaml gets their actual reps, teams and SLAs.
Because it is YAML and hot-reloaded, their ops lead can change owners later without me.
4 — Connect the channels (1 day). Web forms POST to /api/leads. WhatsApp arrives via
their BSP's webhook. Portal feeds (Bayut, Property Finder, Zillow) either push or land as
scheduled CSV into /api/leads/import. Outbound goes to their Slack and their CRM.
5 — Deploy. Docker image to Railway, Render or Fly.io; SQLite on a persistent volume for
small volumes, Postgres via DATABASE_URL above ~50k leads. Full step-by-step commands —
written to be executed literally, including smoke tests and rollback — are in
DEPLOY.md.
6 — Go live in demo mode first. DEMO_MODE=true in production for the first week: real
leads, real scoring, real routing, webhooks recorded but not sent. The client compares the
engine's decisions against their reps' decisions on the same leads. When they trust it, flip
one environment variable.
7 — Hand over. They own the YAML, the keyword banks and the dashboard. The scoring is explainable by design, so their team can defend and adjust it without me in the room.
- Enrichment is an offline bundled dataset, not Clearbit/Apollo. It is fast, free and private —
and correspondingly shallow outside the domains it knows.
enrich()is a single function to swap for a real provider. - FX rates are static constants, adequate for budget banding, not for accounting.
- Language detection is script + phrase based; it reliably distinguishes major scripts and common European languages, but is not a full classifier.
- The pipeline is synchronous. That is a deliberate trade for sub-second latency at inbound volumes; at very high throughput you would move the LLM pass onto a queue.
- The dashboard is read-plus-respond, not a full CRM. It is a triage surface, not a system of record.
MIT — see LICENSE.