Build specs / Lead dashboard
Raw markdown for AI agents →

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.

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:

StoredShowNot
a valuethe value
0 / empty and enrichment_status = 'enriched'No / Nonea blank cell
NULL / empty and any other status with a tooltip naming the statusNo

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:

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.


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, then the change monitor.

Lyre Leads · Home · Agent guide · Build specs · API docs · Data dictionary