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, the inbox, and inside each conversation's right rail โ
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 four capped weights, clamps the
total to [0, 100], then maps the result onto three
buckets:
| Signal | Weight | Cap |
|---|---|---|
| Unique pages visited | +5 per distinct URL | 25 |
| Intent pages (URL contains pricing, demo, contact, buy, signup, checkout, cart, purchase, order) | +15 per matching page | 30 |
| Chat engagement (messages exchanged) | +2 per message | 30 |
| Lead captured (email present) | +15 flat | 15 |
Bucket thresholds: Hot โฅ 70 (green),
Warm 40โ69 (amber), Cold 0โ39 (grey).
A visitor who lands once, sends two messages, and disappears scores
9 (Cold). A visitor who hits four pages including /pricing,
chats for 8 turns, and submits their email scores 75 (Hot).
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. -
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 inbox / leads list
Both /app/inbox and each agent's
/app/agents/{id}/leads page expose 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.
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.