Find out where your leads died, from the files you already have.
Drop in a folder of WhatsApp exports, a call log and a lead list. FollowThrough finds the conversations where your prospect spoke last and nobody came back, grades how fast anyone answered, and checks the same timestamps against the UAE marketing rules — on your own machine, with no account, no upload and no API key.
Live demo · Quickstart · How it works · Configuration · AI model · Limits · Security
- What this is
- Quickstart
- How the system works
- What it measures
- The compliance layer
- Outputs
- Configuration
- The optional AI layer
- Command line
- Project structure
- Dependencies
- Measured results
- What this does not claim
- Security and privacy
- Development
A local, file-in / report-out diagnostic for sales follow-up.
A Dubai brokerage spends AED 25,000 a month on Property Finder and Bayut, and the leads arrive. Somebody WhatsApps them. Some of those conversations turn into viewings. Most of them just stop.
Ask the CRM what happened and it will tell you about the calls it logged. It cannot tell you that on 14 March a buyer asked "is the 2BR still available?" and nobody ever replied — because that conversation happened on an agent's personal WhatsApp, which the CRM has never seen. That is where the money goes.
Every speed-to-lead audit on the market reads a CRM, and every WhatsApp analytics product reads its own Business-API platform. Both need the prospect to onboard before they can be told anything, which structurally prevents either from reporting on what already happened. FollowThrough reads the export.
It is a diagnostic, not a sender. It never messages anyone, never calls anyone, and has no CRM integration. The absence of an integration is the point: nobody has to trust it with access to anything.
Requires Python 3.11 or newer. No API key, no account, no config file.
git clone https://github.com/devvaham/FollowThrough && cd FollowThrough
pip install -e ".[server]"
uvicorn followthrough.api:appOpen http://localhost:8000 and click Try it on a sample brokerage — twelve invented conversations with known problems planted in them, so you can see the whole report before you feed it anything of your own.
Command line only:
pip install -e .
followthrough autopsy ./exports leads.csv calls.csv --report audit.pdf --csv flagged.csv| Input | Where it comes from | Required? |
|---|---|---|
WhatsApp exports (.txt) |
In WhatsApp: open a chat → Export chat → Without media. One chat, a whole folder, or a .zip of hundreds. |
The primary input |
Lead list (.csv/.xlsx) |
Property Finder, Bayut, Meta Lead Ads, or a CRM export. Needs an enquiry timestamp. | Optional, but response speed cannot be measured without it |
Call log (.csv/.xlsx) |
Your dialer, VoIP provider or CRM. | Optional |
Point it at any combination — it works out which file is which from the columns, not the filename. A lead list zipped up with the exports is found and used.
files in ──▶ parse ──▶ join ──▶ measure ──▶ one audit ──▶ three outputs
1. Parse. whatsapp.py reads the exports: iOS and Android formats, 12- and
24-hour clocks, Arabic ص/م markers, the Unicode direction marks that wrap
every timestamp in an Arabic-locale export, multi-line messages, system notices,
<Media omitted>, deleted and edited markers, call-log lines, and group chats it
skips with a reason. tables.py reads the CSV/Excel side — encodings, delimiters,
title rows above the headers, ragged lines.
Date order is decided across the whole upload, not file by file. 03/04 is
genuinely ambiguous; a short thread where every day is 12 or lower cannot settle
it alone, but a sibling file usually can. Where nothing settles it, the
assumption is carried into the report rather than made quietly.
2. Join. phones.py normalises every number to E.164 — that is the join key.
threads.py assembles one Thread per lead from every source, and works out
which side of each conversation was the agent (whoever appears in more than one
conversation). Both decisions are shown to you before anything is computed.
3. Measure. metrics.py computes speed, persistence and thread death;
compliance.py reads the same timestamps against the marketing rules;
money.py does the arithmetic on figures you supply. All deterministic — same
files, same answer, every time. No model is involved in any measurement.
4. Report. audit.py flattens everything into one dictionary that the
browser, the PDF and the CSVs all read from, so the three cannot disagree. Every
finding carries the messages that produced it.
Two conventions worth knowing:
- "As of" is the latest timestamp in your data, not today. Re-running a six-month-old export next year gives the answer it gave the first time.
- A conversation is only judged once it is dead (14 days of silence by default). Live threads are counted and set aside.
| Speed | Time from the lead arriving to the first message or call from a person. Automatic acknowledgements are detected and ignored — they are not a response, and the clock keeps running. Median, p90, worst case and a full distribution, graded A–F. Never a bare average. |
| Persistence | Attempts per lead, with several messages inside ten minutes collapsed into one — a three-line greeting is not three follow-ups. Shown against the reply rate at each attempt count, from your own file. |
| Thread death | Every dead conversation classified by who spoke last: you went quiet, they went quiet, never contacted, or closed. The first is the money metric, and nothing else on the market computes it. |
| Died on a buying signal | The subset where the prospect's last message was a request to view, a price question, or "is it still available" — in English, Arabic or the Arabizi that actually appears in Dubai. The matched phrase is always shown. |
| By agent | Median first response, median reply time inside a conversation, attempts per lead, ghost rate, out-of-hours contacts. With an anonymise toggle so the report can circulate internally without naming anyone. |
| Cost of the leak | Your commission, your conversion rate, your ad spend. There is no default and no industry benchmark anywhere in the tool. Supply nothing and the report says how many leads leaked and declines to price them. |
The same timestamps, read against the published rules. Nearly free once the parsing is done, and as far as our research could establish, nobody else does it.
| Check | Reported as |
|---|---|
| Calls placed outside 09:00–18:00 | A breach of Cabinet Resolution 56 of 2024, instrument named |
| Messages sent outside 07:00–21:00 | A flag, not a breach — see below |
| More than one unanswered call in a day, or two in a week | A breach, with the offending calls listed |
| Contact after the person declined | A breach if the same day, otherwise flagged for a human to read |
| Calls logged with no duration and no outcome | A gap in the record the rules require |
| Property listings sent with no permit number | A prompt to ask your legal advisor, not an assertion |
The distinction that makes this credible: Resolution 56 regulates marketing by telephone call. Marketing SMS sits under a separate, older TDRA policy with a different window. WhatsApp business messaging sits cleanly under neither. So a call outside the window is reported as a breach of a named instrument, and a message outside the window is reported as what it is — outside the window a regulator applies to marketing messages, which is worth knowing and is not the same thing. Getting that wrong would discredit the rest of the report.
The section states the published fine schedule as fact about the instrument, never as a prediction about the reader; says in the tool that it is not legal advice; and says plainly that it cannot check the Do Not Call Registry, because that requires a telecom operator.
| Output | What it is |
|---|---|
| The browser report | Sticky summary, section rail, charts, filterable findings, and a drawer that opens any finding onto the actual conversation with the unanswered message ringed. Light and dark. |
| Client-ready. Opens with a plain-English paragraph a non-technical brokerage owner reads in twenty seconds, shows the messages rather than only counts, and pairs every problem with what to do about it. White-labellable with your own name and logo. | |
leads.csv |
Every lead, worst first, with its classification — so somebody can act on it on Monday. |
findings.csv |
Every individual finding with its evidence, for the compliance detail. |
Everything is optional; the tool runs with no configuration at all. Copy
.env.example to .env to change any of it. .env is
gitignored from the first commit and must never be committed.
| Variable | Default | What it does |
|---|---|---|
FOLLOWTHROUGH_MAX_UPLOAD_MB |
50 |
Largest upload accepted, checked before the body is read |
FOLLOWTHROUGH_MAX_MESSAGES |
500000 |
Message ceiling for one run. Drops whole conversations rather than truncating one, and says which |
FOLLOWTHROUGH_MAX_FILES |
5000 |
Files read from one upload |
FOLLOWTHROUGH_TIMEZONE |
Asia/Dubai |
WhatsApp exports carry no timezone. Every time-of-day check depends on this, and the report prints it |
FOLLOWTHROUGH_REGION |
AE |
Default country for bare national phone numbers |
FOLLOWTHROUGH_ALLOW_ORIGINS |
(empty) | Cross-origin access. Empty means same-origin only, which is what you want. Name origins explicitly if you widen it — never * |
FOLLOWTHROUGH_AI_PROVIDER |
(empty) | nvidia, anthropic, openai or openai_compatible |
FOLLOWTHROUGH_AI_API_KEY |
(empty) | Leave empty and the AI layer simply never runs |
FOLLOWTHROUGH_AI_MODEL |
(empty) | Blank uses the provider default |
FOLLOWTHROUGH_AI_BASE_URL |
(empty) | Blank uses the provider default |
.env is read automatically if present. A variable already exported into the
environment always beats the file.
The deterministic engine produces every number in every output. The AI layer, if you switch it on, gets exactly one job: the handful of conversations whose last message the rules refused to classify either way. "ok but what about the other one" is a live buying signal in context and matches no phrase list ever written.
- Off by default. The tool never asks for a key and works fully without one.
- Its answers are labelled as its answers everywhere they appear, and counted separately, so the headline figures cannot move because a provider happened to be reachable.
- It never sees your file. It sees up to four messages from one conversation, with the participants' names already removed.
- If it fails, the run is byte-identical to a no-AI run, with a line saying what was skipped. This is tested, not aspirational.
followthrough check-ai # one round trip, to see whether a key worksProviders: NVIDIA NIM, Anthropic, OpenAI, or any OpenAI-compatible endpoint
(including a model running on your own machine — set openai_compatible and
point the base URL at it).
A warning specific to NVIDIA NIM: GET /v1/models returns NVIDIA's whole
catalogue, but any given account only has a subset actually provisioned.
Everything else returns 404 Function ... Not found for account, which looks
like a typo in the model id and is not one. List what your key can actually
reach:
curl https://integrate.api.nvidia.com/v1/models \
-H "Authorization: Bearer $FOLLOWTHROUGH_AI_API_KEY"See internal/model-selection.md for how the
default was chosen and the measurements behind it.
followthrough inspect ./exports # what did it detect?
followthrough autopsy ./exports leads.csv calls.csv \
--report audit.pdf \ # .pdf or .html
--csv flagged.csv \ # every lead, worst first
--findings findings.csv \ # every individual finding
--json audit.json \ # the raw numbers
--commission 45000 --conversion 3 \ # your figures, never assumed
--spend 25000 \
--client "Meridian Properties" \
--agency "Your Agency" \
--anonymise \ # agents become Agent A, Agent B
--mask-leads \ # leads become Lead 1, Lead 2
--dead-after 21 \ # silence before a thread is dead
--agent "Sara Khalil,Omar Haddad" \ # if agent detection gets it wrong
--date-order DMY \ # if date detection gets it wrong
--calls-are-outbound \ # log has no direction column
--ai # use the configured provider
followthrough check-ai # is the optional AI key working?followthrough/ the package — everything the tool is
├── whatsapp.py the export parser: formats, dates, RTL marks, system messages
├── tables.py CSV/Excel in, rows out: encodings, delimiters, title rows
├── mapping.py which column is which, from headers and cell contents
├── sources.py frames in, Leads and Calls out
├── phones.py E.164 identity — the join key
├── dates.py timestamps out of CSV cells, timezone handling
├── signals.py buying signals, refusals, auto-acks — EN/AR phrase rules
├── threads.py one lead's whole history, and who the agent was
├── metrics.py speed, persistence, thread death, agents
├── compliance.py the UAE checks
├── money.py the arithmetic, with its working
├── audit.py orchestration -> one dict the three outputs share
├── report.py self-contained HTML -> paginated PDF
├── exports.py the CSVs
├── api.py stateless FastAPI
├── ui.html the whole interface: one file, vanilla JS, no build step
├── models.py, settings.py
├── ai/ the optional layer: one provider interface, one task
└── data/sample/ the bundled sample brokerage (invented)
benchmarks/ make_dataset.py, run.py, make_sample.py
tools/build_public_site.py generates docs/ for GitHub Pages
docs/ the public site — project page + live demo (safe to publish)
internal/ agency strategy and notes — DELETE BEFORE PUBLISHING
tests/ 196 tests, none of which touch the network
There is no React build, no node_modules and no bundler. A dropzone, a
mapping table, a findings list and a few charts do not justify a toolchain in a
tool whose selling point is that it runs anywhere with no ceremony.
Deliberately few. Standard library before a dependency; one existing library before custom code.
| Package | Why |
|---|---|
phonenumbers |
Google's libphonenumber. Phone identity is the join key, and reimplementing it would be the worst kind of custom code |
pandas |
The table layer. CSV/XLSX with ragged rows and bad encodings is genuinely fiddly |
openpyxl |
pandas needs it for .xlsx |
| server extra | |
fastapi, uvicorn, python-multipart |
The local server. Stateless, in-memory uploads |
weasyprint |
HTML → PDF without a headless browser |
httpx |
Used only by the optional AI layer |
| dev extra | |
pytest, pypdf |
Tests, and asserting the report really paginates |
Everything else — parsing, dates, zip, CSV writing — is standard library.
benchmarks/ builds a synthetic brokerage — 108 WhatsApp exports across both
platforms, 120 leads, 145 calls, English and Arabic — with known problems
planted in it and the right answer written down before the tool sees the file.
python benchmarks/run.py # measure
python benchmarks/run.py --misses # list every disagreement
python benchmarks/run.py --scale # throughput on a corpus 40x the size| Conversations classified as the label says | 120 / 120 (100%) |
| Prospects left waiting, found | 30 / 30, with 0 false positives |
| Conversations that died on a buying signal | 12 / 12, with 0 false positives |
| Compliance problems planted, across seven checks | 36 / 36, with 0 false positives |
| Auto-acknowledgements that failed to stop the clock | 0 / 12 |
| Files read in the wrong date order | 0 |
| Throughput | 28,320 messages across 4,320 conversations in ~1.6s (≈17,500 messages/second) |
The benchmark is synthetic. It shows the tool does what it claims on data whose right answer is known; it cannot prove behaviour on your export.
Building it alongside the engine found real bugs in both directions — مش مهتم
("not interested") was read as a buying signal because it contains مهتم
("interested"), and three unanswered calls in one afternoon were reported as
both a daily and a weekly breach, counting one mistake twice in front of a client.
A report that overstates its confidence is worse than no report. These are stated in the tool itself, not only here:
- It cannot see what it was not given. A call from a personal phone with no log, or a conversation on a platform nobody exported, is invisible.
- "Left waiting" is about who spoke last, not about who was right. Some were answered by phone, in person, or by a colleague. The evidence is printed so you can tell which.
- Reading intent from a message is a judgement. Buying signals and refusals come from phrase lists in English and Arabic, and the matched phrase is always shown so you can disagree with it.
- A WhatsApp export carries no timezone. Every time-of-day check rests on an assumption the report prints.
03/04is genuinely ambiguous. The date order is settled across the whole upload; where nothing settles it, the assumption is carried into the report.- The compliance section is not legal advice, and it cannot check the Do Not Call Registry.
- The money figure is arithmetic on your assumptions, not a forecast.
- The widely quoted statistic that 44% of salespeople give up after one follow-up is not in this tool, because it traces to a 1942 survey of fewer than forty people attributed to an organisation that does not appear to exist. Your own attempt distribution is a better argument anyway.
Full detail in SECURITY.md, including the findings of a code-level audit. In short:
- Nothing is stored. No database, no session, no cache, no temp file. Uploads are held in memory for one request and never spooled to disk.
- Nothing leaves the machine except the optional AI calls, which are off unless you switch them on.
- No webfont, no analytics, no CDN, no third-party request of any kind — from the app or from the public site.
- Message content is treated as hostile throughout: escaped before it reaches a report, defused before it reaches a spreadsheet, and path-checked before it comes out of a zip.
- The tool has no authentication and is built for
127.0.0.1. Put an authenticating proxy in front of it before putting it on a URL.
pip install -e ".[server,dev]"
pytest # 196 tests, no network
python benchmarks/run.py # builds its dataset on first run
python benchmarks/make_sample.py # regenerate the bundled sample
python tools/build_public_site.py # regenerate docs/ for GitHub Pages
uvicorn followthrough.api:app --reload