The system
How the system worksThe DirectoryDispatchThe LedgerAI modelsAI agentsYour installationThe appThe research libraryWhat changes for your businessUse it from your softwareEverything it can do
Messaging
iMessageWhatsApp
Ads and leads
AdsCreativeLeads
Devices
Devices
Industries
RealtorsRestaurantsHome services
Explore
How it worksPermissions and limitsCompareSet upQuestionsText me

The contact object: what the build already knows about every lead and customer, and why no screen shows it

The Console is the phone-shaped app that operates this build, and two of its screens show people: Leads, which lists 87,048 clinics nobody has contacted, and Loop, which lists the customers of Loop Bio Labs, the supplement business the

The Console is the phone-shaped app that operates this build, and two of its screens show people: Leads, which lists 87,048 clinics nobody has contacted, and Loop, which lists the customers of Loop Bio Labs, the supplement business the build runs. Triple Whale is the ad-attribution service that says which channel an order came from; BigCommerce is the store those orders are placed in; Klaviyo is the email and SMS system. Both screens are not under-designed. They are under-connected. Every number you asked for is already in the database, already refreshed on a schedule, and already reachable through routes this build wrote itself. The two screens that show people — Leads and Loop — read about eight columns between them and throw the rest away.

Measured today against the live database, not inferred.

The Leads screen is showing you the worst 86% of the list on purpose

There are 87,048 leads. The Leads tab is hard-filtered to segment IN ('chiropractor','chiropractic clinic'), so it shows 74,800 of them and hides 12,248.

rows shownscore ≥ 50has an emailhas a written reason
What the Leads tab shows74,800403,5933,524
What it hides12,2481,4553,6263,522

Forty good leads in the list you look at. Fourteen hundred and fifty-five in the list you cannot open. Half of every enriched lead in the build is behind that filter — medical spas, hormone clinics, regenerative medicine, longevity, testosterone. Those segments average a score of 27 to 33. Chiropractors average 3.

That alone explains "it looks like spam." You are looking at 67,428 rows marked new, most with no email, most scoring 2.

The enrichment you are asking for is already written down. Nothing renders it.

Every lead row has a context column — populated on 87,021 of 87,048 rows — holding the site title and meta description. And the enriched ones have a notes column in a fixed grammar:

text
emails:contact@…  mx:ok  icp:100  buyer:medspa_owner  vol:high
why:Explicitly lists 'peptide therapy' alongside Semaglutide and Botox. Perfect fit.
Line 2, Column 1Plain Text

That is your WHY THIS LEAD block. Already computed. Already stored. The Leads row renders name, city, email, stage, score. The detail sheet adds last touch, open/click/reply, and then hands you a compose box. why, icp, buyer, vol, mx, segment, address, website, and the site description are never drawn anywhere on any screen.

The score is shown as a bare integer with nothing behind it. 85 next to a clinic name reads as noise. 85 — men's health clinic, peptides on the menu, MX valid, practitioner-owned reads as a reason.

On the customer side, three separate things are wrong

One. The customer sheet iterates raw row keys and prints them. That is the Object.keys(row).forEach in the Loop tab. It is why the thing feels like a database viewer: because it is one.

Two. The first-order and last-order dates on screen are false. They are read from persons.first_seen_at / last_seen_at, which are ingest timestamps, not order dates. 14,823 of 15,123 person records carry 2026-08-07 — the day the import ran. 2,213 of 2,238 buying customers display a wrong first-order date. One top customer shows "first order 2026-08-07, last order today" on a row whose own days_since_last_order says 9. Their real first order was 2025-09-16.

Three. The same person has two different lifetime values depending on which route you ask. The console reads customer_scores and says 69 orders / $54,404. The build's own LOOP_CUSTOMER capability says 65 orders / $48,814 — the difference is nine cancelled orders that one expression excludes and the other does not. Both numbers are live right now. That has to become one number before it goes on a card.

What the build already holds per person, and shows none of

HeldCoverageSourceOn screen today
LTV, lifetime orders, AOV, revenue/month, days since last order, value tier13,309 peopleBigCommerce → customer_scorespartial, and one figure disputed
Intent score, relationship score, retargeting score, recommended channel, recommended next message, suppress-ads flag13,309 peopleoursnothing
Own reorder cadence, opens, clicks, SMS clicks, checkouts, carts, product views, unsubscribes, spam, email/SMS consent13,309 peopleKlaviyo → score_reason_jsonnothing
First-click source, last-click source, linear source, touch count, campaign / adset / ad id, click time — per order7,827 of 11,043 orders, 1,958 of 2,238 buyersTriple Whale → order_attributionnothing
Raw event stream1,444,346 events over 13,299 peopleKlaviyonothing
Subscription plan, tier, status, interval, next charge, cancel date173 subs over 149 peopleStripe livenothing
Line items, SKU, category, quantity, unit priceevery orderBigCommerceitems list only
Charges, fees, net, card brand, refunds, disputesfrom 2026-05-11Stripenothing

For scale, here is the channel mix sitting unused in order_attribution, last click:

ChannelOrdersRevenue
Klaviyo3,695$1,715,846
Non-attributed1,899$818,956
Direct1,037$543,053
Organic and social527$314,363
Facebook ads399$207,701

That table is the Triple Whale import you said you cannot see. It is there. It has never been drawn on a person.

Where I agree with the shape you described, and the one place I would change it

The contact-card framing is right, and one component rendering both a cold lead and a repeat buyer is right — the fill level differs, the object does not.

Two changes.

The top block is the next action, not the avatar. You already compute a recommended channel and a recommended next message for 13,309 people. Putting "slipping — reorder reminder now" under the name turns a profile into something you act on. An avatar and a phone number is a Rolodex.

The journey chain must be able to say UNKNOWN. 1,899 orders are literally labelled Non-attributed and attribution only starts 2025-11-22. A vertical "discovered via Meta → visited → purchased" chain drawn from that data would be fiction for a quarter of your revenue. The chain renders what is known and prints Not attributed where it is not. Same rule for the 3,216 orders with no attribution row at all.

On provenance: agreed, and it should be a tap, not a label. Every source chip opens the row it came from — this build already writes a receipt for every tenant read, so the chip has somewhere real to land.

What to build, in order

First, correctness. A card that shows a wrong date is worse than a table that shows none.

  1. functions/_lib/tenant.js → listCustomers: take first_order and last_order from min(order_date) / max(order_date) on the orders table, never from persons.first_seen_at.
  2. Settle one LTV expression — cancelled excluded — and make customer_scores, LOOP_CUSTOMER and the console all read it. One number, one definition, quoted the same everywhere.
  3. Unescape HTML entities when rendering context. It is stored as Men's Health and will render that way the moment you put it on screen.

Second, one endpoint that composes the person.

functions/api/console/person/[[path]].js — GET ?lead=<id> or ?person=<person_id|email>, answering one shape whichever you pass:

text
identity     name, company, role pills, location, email, phone, channels
value        ltv, orders, aov, revenue_per_month, first_order, last_order, refunds, subscription
acquisition  first_click, last_click, campaign, touch_count, new_vs_returning, discovered_from
engagement   last_message, opens, clicks, sms_clicks, carts, checkouts, views, consent
relationship stage, next_action, recommended_channel, why, icp, buyer_type, volume, mx
timeline     merged orders + klaviyo events + outreach, newest first
orders       line items
raw          everything, behind one tap
provenance   { field: bigcommerce | triplewhale | klaviyo | stripe | google-places | ours }
Line 9, Column 1Plain Text

Every figure carries its source in provenance. That is the only way the icons can be honest rather than decorative.

Third, one renderer.

public/console/profile.js — header, the four blocks above, orders, timeline, and More → Raw object last. Both tabs call it. Nothing else renders a person.

Fourth, wire the two tabs.

  • public/console/loop.js: openRow for a customer calls the profile. Delete the raw key dump.
  • public/console/leads.js: tapping a lead opens the profile. The compose box moves to an Outreach section inside it, below the reason to contact them. Stage chips stay as filters. The chiropractor filter becomes a segment chip instead of a hard gate, so the 1,455 good leads become reachable.

Fifth, the icons.

public/console-logos.js already holds monochrome marks and already carries Meta, Apple and Cloudflare. Add Triple Whale, BigCommerce, Klaviyo, Stripe, Gorgias and Google Places in the same shape, and bind each to its provenance key.

The one-line version

Nothing here needs a new data source, a new import, or a new integration. Every number you asked for exists, is current, and is already paid for. Four files stand between it and the screen.