R RockAI 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, 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:

SignalWeightCap
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/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.
  • 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 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.