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. Every number below is a default: each
workspace can change them in Settings โบ Leads (see
Change the points and thresholds).
| Signal | Default weight | Default cap |
|---|---|---|
| Unique pages visited | +5 per distinct URL | 25 |
| Intent pages (URL contains pricing, price, demo, contact, buy, signup, sign-up, checkout, cart, purchase, order) | +15 per matching page view | 30 |
| Chat engagement (messages exchanged) | +2 per message | 30 |
| Lead captured (email present) | +15 flat | 15 |
Default 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 views four pages including
/pricing (20 + 15), exchanges 10 messages (20) and
submits their email (15) scores 70 (Hot).
Change the points and thresholds
Owners and Admins open Settings โบ Leads
(/settings/leads). The Lead scoring panel
lists the four signals with a short explanation each, and for each
one the points per page, visit or message and the maximum it can
add. It also holds the intent keywords (comma separated, matched
against the page address, upper and lower case alike) and where
Hot and Warm start โ anything
below Warm is Cold.
- Points and maximums are whole numbers from 0 to 100. A signal worth 0 points is switched off and no longer appears in the "why this score" list.
- Warm starts at 1 or more, and Hot must start above Warm.
- An empty keyword list switches intent pages off.
- The panel shows the highest score your points allow, and warns when it is below the Hot threshold โ then nobody can become Hot.
- Restore defaults refills the form with the values above; nothing changes until you save.
Settings are stored per workspace in
workspaces.settings.lead_scoring and cached; saving
replaces the cached copy, so the next page view, message or lead
capture is scored with the new values. A workspace that never saves
keeps scoring exactly as described above.
Recalculate scores
Saved changes apply to new activity. To re-score existing
conversations, press Recalculate scores under the
form. It queues a recompute for the workspace's conversations that
started in the last 90 days (playground sessions
excluded, newest first, at most 20,000), one
RecomputeLeadScoreJob per conversation on the
analytics queue โ the visitor hot path is never
involved. The button waits two minutes between runs. Older
conversations keep their score until their next page view, message
or lead capture, or until you run
php artisan porifyx-chat:recompute-lead-scores (which uses
each workspace's own settings too).
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": "[email protected]", "status": "new", "status_label": "New", โฆ },
"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 porifyx-chat: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 conversation is in the Hot bucket (scored at or
above the workspace's Hot threshold, 70 by default), so the list
always agrees with the badges. After changing the thresholds, use
Recalculate scores to move existing conversations
into their new buckets. The toggle is bookmarkable: the URL gets
?hot=1 and survives refresh.
Tuning
Weights, caps, intent keywords and thresholds are per-workspace
settings โ see Change the points and
thresholds; no code change is needed. Their defaults live in
App\Services\Scoring\LeadScoringSettings::defaults()
(the default keyword list is
LeadScoringEngine::INTENT_KEYWORDS).
To add a new signal, give LeadScoringSettings
a points (and cap) field whose default keeps today's behaviour, add
it to the Settings โบ Leads form, then write a private
scoreFoo() method in
app/Services/Scoring/LeadScoringEngine.php that returns
['score' => int, 'reasons' => string[]] and add
it to the score() 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.
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.