B Blengi docs

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.

SignalWeightCap
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/init writes a visitor_page_views row each time the widget boots on a fresh page (same-page reloads within 2 minutes are deduped), then enqueues a recompute.
  • New turn persisted — PersistTurnJob appends 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 one RecomputeLeadScoreJob per 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 the chunkById iterator (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.