Run your workspace
Lead scoring & trajectory
Every conversation gets a lead score on a 0–100 scale
derived from observable signals: which pages the visitor browsed, how
deeply they engaged in chat, and whether they shared contact info.
The score appears as a coloured pill on the Conversations list, the
Leads list, and inside each conversation's side panel —
so sales can prioritise the hot prospects without re-reading every
transcript. Webhook consumers also get the score in the
lead.captured payload, so a CRM can route hot leads to
a different queue automatically.
How the score is computed
The LeadScoringEngine sums six capped weights, clamps the
total to [0, 100], then maps the result onto three
buckets. Since card #791 the weights follow buying intent, not
how much someone chats: a long conversation on its own never makes a
lead hot.
| Signal | Weight | Cap |
|---|---|---|
| Pages visited | +3 per distinct page | 15 |
| Buying or contact pages — the address contains a word such as pricing, cart, checkout, order, quote, contact (English), prijzen, offerte, winkelwagen, afrekenen, bestel, afspraak (Dutch), Preise, Warenkorb, Kasse, Angebot, Termin (German) or tarif, panier, commande, devis (French), at the start of a word | +10 per matching page | 20 |
| Asked about buying or contact in the chat — a visitor message about price, ordering, a quote, a demo, an appointment or being contacted, in English, Dutch, German or French | +10 per message | 20 |
| Clicked a product card | +10 | 10 |
| Clicked a button in the chat | +10 | 10 |
| Visitor messages (only the visitor's, not the assistant's) | +2 per message | 10 |
| Contact details left (email present) | +20 | 20 |
Bucket thresholds: Hot ≥ 70 (green), Warm 40–69 (amber), Cold 0–39 (grey). A visitor who sends thirty messages without asking to buy scores 10 (Cold). A visitor who views a product and the cart, asks the price, clicks the product card and leaves their email scores 58 (Warm); if they also click a button and ask to be called back, 80 (Hot). The reasons behind a score — shown when you hover the pill and in the conversation's side panel — appear in your dashboard language.
Scores changed when this formula shipped, so a webhook consumer that
routes on score_bucket sees fewer chatty conversations
marked high. The payload's shape is unchanged.
How it stays fresh
Recompute is queued — never on the SSE hot path. The
RecomputeLeadScoreJob fires from three places:
-
New page view —
/api/v1/widget/initwrites avisitor_page_viewsrow each time the widget boots on a fresh page (same-page reloads within 2 minutes are deduped), then enqueues a recompute. -
New turn persisted —
PersistTurnJobappends every assistant + user turn to the messages table and enqueues a recompute on its way out. - Click — a product-card or chat-button click reported by the widget enqueues a recompute (card #791).
-
Lead captured — when the visitor submits the
lead form the controller runs the recompute synchronously
before dispatching
RouteLeadJob, so the webhook payload sees the up-to-date score.
Visitor trajectory
Behind the score sits a per-visitor browsing trail. Every page the
widget mounts on records a row in visitor_page_views:
id bigserial
workspace_id uuid -- tenancy scope
agent_id uuid
visitor_id uuid
conversation_id uuid -- nullable (set when the page view happens
-- inside an active conversation)
url varchar(500)
title varchar(200) nullable
referrer varchar(500) nullable
viewed_at timestamptz
created_at timestamptz
The conversation detail page (/app/conversations/{id})
renders the last 50 page views in chronological order in the right
rail — pages visited during the conversation get a green
border so the operator can tell mid-chat browsing from earlier
visits.
Webhook payload
The lead.captured event now ships with three extra
fields:
{
"event": "lead.captured",
"lead": { "id": "01h…", "email": "buyer@example.com", … },
"agent_id": "01h…",
"score": 75,
"score_bucket": "high",
"trajectory": [
{
"url": "https://example.com/pricing",
"title": "Pricing — Plans",
"referrer": "https://google.com",
"viewed_at": "2026-05-18T10:14:02+00:00"
},
…
]
}
The trajectory is capped at the 10 most recent views to keep
payloads small. score_bucket is one of
low, medium, high.
Why this score? (reasons array)
Every recompute also persists the line-by-line breakdown into
conversations.lead_score_reasons (JSON). Each row is a
human-readable string the engine emitted on the way to the total —
e.g.
"Visited 3 unique pages (+15)",
"Visited 2 intent pages (pricing, demo) (+30)",
"Sent / received 5 messages (+10)",
"Contact info captured (+15)". The conversation detail page
renders the list under the Lead score section so the operator knows
exactly which signals fired; the badge itself shows the same lines
in its tooltip on hover. When a signal contributes 0 it is omitted —
a cold lead with no intent matches simply lists fewer items.
Backfill existing conversations
The score column defaults to 0 for every conversation that existed before this feature shipped. To bulk-recompute against history, run:
php artisan pitchbar:recompute-lead-scores
Useful flags:
-
--workspace={uuid}— restrict to one workspace -
--agent={uuid}— restrict to one agent (overrides--workspace) -
--queue— dispatch oneRecomputeLeadScoreJobper conversation instead of running inline. Use this for very large workspaces (millions of conversations) where the analytics worker should soak up the work in the background. -
--chunk=500— row batch size for thechunkByIditerator (default 500). Drop to 100 on memory-constrained hosts.
The command is idempotent: re-running it just overwrites the score + reasons with the freshest values. Playground conversations are always skipped.
Zero-score conversations
A brand-new conversation with no page view, no messages, and no
captured contact info scores 0 and falls into the
Cold bucket. The UI hides the badge entirely when the score is 0 —
avoids a row of grey "Cold · 0" pills on every drive-by widget
load that never triggered a turn. Conversations only start showing
a badge after the first scoring signal fires (usually a page view
via /init on a non-blank page URL).
Filtering the leads list
Leads (/app/leads) has a Hot
only toggle in the toolbar — when on, the list is constrained
to leads whose underlying conversation scored ≥ 70. The toggle is
bookmarkable: the URL gets ?hot=1 and survives refresh.
One AI employee's leads are the same list filtered to it
(/app/leads?agent={id}&hot=1); the old
/app/agents/{id}/leads address forwards there.
Tuning
The weights live in
app/Services/Scoring/LeadScoringEngine.php. Add a new
signal by writing a private scoreFoo() method that
returns ['score' => int, 'reasons' => string[]]
and adding it to the compute() parts array. Keep the
sum of caps reasonable — total clamps to 100 either way, but
over-weighting a single signal makes the bucket boundaries
meaningless.
To extend the intent-keyword list, edit
LeadScoringEngine::INTENT_KEYWORDS. Case-insensitive
substring match against the URL only — adding a keyword does not
re-score historical conversations until the next recompute fires
(i.e. the next page view, message, or lead capture on that
conversation). Use php artisan pitchbar:recompute-lead-scores
to force a bulk rescore against existing rows.
The engine caps page-view history at the most recent
200 rows per visitor
(LeadScoringEngine::MAX_PAGE_VIEWS). Beyond that the
weights saturate anyway, and the cap keeps the query cheap for
long-lived returning visitors.