A single-user application that runs on your user's machine and gives them their Lyre Leads database with their own filters, their own columns, and one-click export.
Build this first. It is the smallest thing that is useful on its own, and the other three specs extend it.
Before you build: ask. See https://lyreleads.com/build for why, and for the four rules that apply to every build on this API.
/api/v1/results into a local store.That is the whole v1. Resist adding more until they have used it.
A small local backend plus a browser UI. The backend exists for one reason: the API key must never reach the browser. Everything else is negotiable.
browser UI ──► local backend ──► api.lyreleads.com
(no key) (holds the key)
Use whatever stack the user already has. If they have no preference, a single Node/Express or Python/FastAPI file plus one HTML page is enough and keeps the whole thing readable. Do not reach for a framework, a build step or a database server for a single-user local app.
Store leads in SQLite (or a JSON file if the volume is small). The point of the local store is that filtering, sorting and re-reading are instant and free, and only *acquiring* data touches the API.
Mirror the API's field names. Do not rename things: when the user reads the data dictionary they should recognise what they see.
CREATE TABLE leads (
id INTEGER PRIMARY KEY, -- Lyre Leads result id
name TEXT,
website TEXT,
phone TEXT,
phone_e164 TEXT,
address TEXT,
city TEXT,
country TEXT,
type TEXT, -- Google Maps category
rating REAL, -- NULL = no reviews yet, not "bad"
review_count INTEGER, -- 0 is a real value
query TEXT,
search_id TEXT,
email TEXT,
email_verification_status TEXT, -- verified|published|catch_all|unknown|invalid
decision_maker_email TEXT,
contact_name TEXT,
contact_title TEXT,
cms_platform TEXT,
crm_platform TEXT,
chat_widget TEXT,
booking_system TEXT,
has_google_analytics INTEGER, -- 1 | 0 | NULL (NULL = unknown)
has_ssl INTEGER,
ai_crawler_access TEXT, -- open|partial|blocked|unknown
ai_rating TEXT,
ai_notes TEXT,
enrichment_status TEXT, -- THE master key for every blank
synced_at TEXT
);
The NULL columns are load-bearing. Do not default booleans to 0 on insert. NULL means "we never checked", 0 means "we checked and it is not there", and collapsing them destroys the single most valuable property of this data. If your storage layer coerces NULL to 0, fix the storage layer.
GET /api/v1/results?limit=100&offset=0
Authorization: Bearer lyre_...
Page until has_more is false. Upsert on id. This is free, so sync on startup and behind a refresh button.
Optional search_id and query params narrow it if the user has a large database and only wants one batch.
Default columns: name, city, phone, email + its verification state, website, rating, reviews, and whatever two enrichment fields the user actually sells against. Let them add and remove columns and remember the choice.
Rendering the three states is the part people get wrong. For every enrichment field:
| Stored | Show | Not |
|---|---|---|
| a value | the value | |
0 / empty and enrichment_status = 'enriched' | No / None | a blank cell |
NULL / empty and any other status | — with a tooltip naming the status | No |
An empty cell that means "unknown" and an empty cell that means "confirmed absent" must not look the same. This is the whole reason the data is worth more than a scrape, and a careless table throws it away.
Same for email: show the state as a badge next to the address. verified and published are both sendable and are not the same thing. catch_all is not a confirmation.
The ones that earn their place, because they map to how someone actually sells:
verified, or verified + publishedcrm_platform IS NULL AND enrichment_status = 'enriched'ai_crawler_access = 'blocked')Show the result count next to the filters, always. The user should never wonder how big the current view is.
The one action here that costs money, so it gets the most care.
pages × 20 tokens, and that duplicates are free so the real number is usually lower. Show their current balance from /api/v1/balance next to it.POST /api/v1/search with {query, city, country, pages}.search_id, tokens_used and tokens_remaining. Show all three.GET /api/v1/results?search_id=... every 10–15 seconds and update the rows as fields fill in.Tell the user plainly that one search is a sample, not a market. Google caps a text search well below the true count. If they want coverage, they want the territory runner.
Export exactly what the filter shows, not the whole database. Nothing erodes trust in a tool faster than an export that does not match the screen.
CSV: UTF-8 with BOM, CRLF, so it opens correctly in Excel in any locale.
Include the state columns alongside the values — email and email_verification_status, not just the address. If the user hands this file to someone else, the states are what stop it being misused.
Offer "verified only" as a preset, and make it mean verified only, not verified plus anything hopeful.
Coercing NULL to false. Covered above, and it is the one that matters most.
Charging twice for the same lead. Searching the same keyword and city again is free for businesses already saved — the API only charges for new ones. Do not build a "refresh" that re-searches; build one that re-reads /api/v1/results, which is free.
Treating rating: null as a zero rating. A business with no reviews has no rating. Sorting ascending by rating will float them all to the top and they are not bad businesses, they are new ones. Sort nulls last.
Treating reviews: 0 as missing. It is a real value and a real signal.
Blocking the UI on enrichment. It fetches every site in the batch. Show progress, let them keep working.
Rate limits. Back off on 429. Do not hammer the poll loop; 10–15 seconds is plenty.
Silent auth failure. On 401, say the key is invalid or revoked. On 403, say API access needs a Growth plan and link /pricing. Do not show an empty table and let them guess.
The test is whether the user stops opening the web app for their daily work. If they still do, ask them what is missing there and build that, rather than adding features nobody asked for.
Natural next steps, in order of how often people actually want them: saved filter presets, then the outreach queue, then the change monitor.