# Build spec: local lead dashboard 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. --- ## What it does 1. Pulls the user's saved leads from `/api/v1/results` into a local store. 2. Shows them in a filterable, sortable table with the columns *they* care about. 3. Lets them run a new Google Maps search without leaving the app, with the token cost shown before they confirm. 4. Exports the current filtered view to CSV. That is the whole v1. Resist adding more until they have used it. --- ## Shape 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. --- ## Data model Mirror the API's field names. Do not rename things: when the user reads the data dictionary they should recognise what they see. ```sql 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. --- ## Sync ``` 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. --- ## The table 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. ## Filters The ones that earn their place, because they map to how someone actually sells: - Business type, city, search keyword - Has email / no email (three-state aware) - Email state is `verified`, or `verified` + `published` - Has a decision-maker email - No CRM detected — meaning `crm_platform IS NULL AND enrichment_status = 'enriched'` - No analytics detected — same pattern - Blocked to AI crawlers (`ai_crawler_access = 'blocked'`) - Rating below X with more than Y reviews — a business with real volume and a real problem - AI score above X Show the result count next to the filters, always. The user should never wonder how big the current view is. --- ## Running a search The one action here that costs money, so it gets the most care. 1. User enters keyword, city, country, pages. 2. **Before confirming**, show the cost: up to `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. 3. On confirm: `POST /api/v1/search` with `{query, city, country, pages}`. 4. Response carries `search_id`, `tokens_used` and `tokens_remaining`. Show all three. 5. Enrichment now runs in the background on our side. Poll `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](https://lyreleads.com/build/territory-runner.md). --- ## Export 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. --- ## Traps specific to this build **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. --- ## When it is working 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](https://lyreleads.com/build/outreach-queue.md), then the [change monitor](https://lyreleads.com/build/change-monitor.md).