Skip to main content

Nox Funnel

Behaviour-derived lead stages: cold → intermediate → warm → booked, plus a manual terminal won and a very-warm priority flag (not a stage). backend owns the engine (derivation, jobs, GHL writes); ms-ai only relays it to Nox via tools and brief blocks; frontend renders the board (Dashboard → Funnel tab). Limited beta.

signal sources engine (backend)
GHL webhooks (opens/clicks/tags) ─┐
kk-click / web visits ─┤
FunnelSweepJob (sessions/calls/ ─┼─► FunnelStageService.recordSignal()
chats, every 10 min) ─┤ dedupe → $inc counter → derive stage
ms-ai conversation outcomes ─┘ (upward-only) → very-warm flag

stage change ──► GHL stage-tag mirror (`nextlevel stage:<stage>`)
very-warm flip ──► NoxFunnelInterceptService (in-app + push, capped)
nightly ──► FunnelDormancyJob (step-down demotion)
NoxFunnelReconcileJob (behaviour vs mapped GHL pipeline)

Stages

StageHow it's earned
coldentered the funnel (Agent K push, feed tag, form, opportunity seed)
intermediatefirst visit/open/click, opens ≥ email_open_threshold (3), answered call, reply, chat, score_bands.intermediate
warmvisits ≥ 2, session ≥ 120s, high-intent pages, re-open days ≥ 2, multi-link clicks, score_bands.warm; instant-warm signals (instant_warm_signals, default booking_start)
bookeda booking — on any calendar, or only the booking_calendar_ids allowlist when set (c37b5e9ac; every appointment still mirrors into the identity graph, only the funnel signal is gated)
wonmanual/terminal onlymoveToWon; the auto engine never derives or demotes it

very_warm is a flag with expires_at (demotion.very_warm_hold_days, 14d, re-extended by engagement). OR-rules in FunnelStageService.deriveVeryWarm: same-day visits ≥ 2, one campaign opened ≥ 4×, contact shared, positive conversation, multi-channel within 24h, spike vs baseline, score band. Promotion is upward-only; demotion belongs exclusively to the dormancy job (step-down one stage at a time, with a stage_baseline snapshot the contact must re-earn against). conversation_negative demotes one step immediately.

Data model (backend)

CollectionModelNotes
funnel_contactsapp/models/FunnelContactModel.jsone doc per (company, contact), keyed by ghl_contact_id OR person_id (partial-unique each; merged on stitch). $inc signal counters, recent_signals[] window, stage_history[], very_warm{}, won_details{}
nox_funnel_audit_logsNoxFunnelAuditLogModel.jsevery decision (dedupes, gates, flips, intercepts) — TTL 120 days (live deployments need a manual collMod)
funnel_daily_statsFunnelDailyStatsModel.jsper (company, local day) census/transitions/dwell — the math engine's input
funnel_reconciliation_itemsNoxFunnelReconItemModel.jsbehaviour stage vs CRM layer mismatches, deduped review queue
brief_offersBriefOfferModel.jsexecutable brief offers, valid_until ≈ 48h (read-time gate, no TTL delete)
lead_lifecycle_eventsLeadLifecycleEventModel.jsappend-only lifecycle (stage_changed, showed, closed_won, …)

Config lives in company.nox_funnel.* (strict subdoc — undeclared keys drop silently): thresholds.* (incl. chat_messages_warm, default 3 — settings-editable), score_bands, demotion.* (90/60/14/14 days), feeding.{tags, tag_rules[{tag,stage}], forms_enabled, web_visitors_enabled, opportunities_enabled, last_sync_summary}web_visitors_enabled = "feed from identified website visitors" (identified visitors may CREATE members; off = their signals still count for existing members); tag_rules.stage and pipeline_mapping.layer now accept won (customer tag/stage → enters as customer, not open lead; won stays terminal — reconcile promotes silently, never queues won for demotion), high_intent_pages, instant_warm_signals, stage_tag_prefix (default nextlevel stage:, blank = off — unchanged), goals.monthly_booked_target, pipeline_mapping.pipelines[{pipeline_id,stage_id,layer}], actions.{workflow_trigger_mode, frequency_cap, standing_rules}, intercept.enabled, outcomes.{positive_intermediate, positive_warm, positive_very_warm, negative_demotes} (conversation-outcome toggles; the first two default false, 91073e0f4), learned.{thresholds,history} (self-learning). Added 2026-09: reconciliation.{auto_promote, auto_demote} (both default falseNoxFunnelReconcileJob._autoApply runs after each scan with action: 'add_tag', actor job:NoxFunnelReconcileJob); pipeline_mapping.layer enum gains ignore (contacts seen only in ignored stages are deleted from the funnel after a healthy, non-truncated scan, never won; summary key removed_ignored); thresholds.link_clicks_intermediate (default 1; PATCH enforces 1 ≤ intermediate < multi_link_clicks_warm); booking_calendar_ids: [String] (default [] = any calendar; GET …/nox-funnel/ghl-options also returns calendars: [{id, name, isActive}], and a calendar deleted in GHL no longer fails a settings save, 3fa5cfcd7). Very-warm lists (overview, internal endpoint, brief) exclude won.

Jobs (backend SchedulerService)

JobScheduleDoes
FunnelSweepJobevery 10 minfolds session analyses / calls / chats into recordSignal (20-min lookback, dedupe-safe)
FunnelDailyStatsJobhourly (once per local day)yesterday's census rollup into funnel_daily_stats
KnoxGhlSyncJob03:00 UTC(pre-existing) GHL sync the funnel jobs depend on
FunnelDormancyJob04:00 UTCstep-down demotion, very-warm hold expiry, reactivation flagging
NoxFunnelReconcileJob04:30 UTCmapped-pipeline scan → recon queue; feed-tag scan; closed-won/lost polling
FunnelBackfillJob05:00 UTC + manualidempotent history backfill after gating a tenant (kk_tokens → lifecycle replay → bookings)
NoxFunnelLearningJobMon 05:00 UTCdemote-only self-learning: repeated rejections (n≥5, ≥60%) raise a threshold one notch into nox_funnel.learned
DailyBriefEmailJobevery 10 min ticksends the brief once/local-day at knox.daily_brief.send_hour (default 9; funnel tenants set 5)

Very-warm intercept

app/services/NoxFunnelInterceptService.js — fired in-process on a very-warm flip: in-app + push notification + nox-funnel-very-warm socket. Redis-capped 1/contact/day and 5/company/hour (code constants; fails open without Redis). Throttles are audited (intercept_throttled).

GHL integration

  • Feeding — contacts carrying a feeding.tag_rules tag enter at that stage (upward-only against an earned stage); legacy flat feeding.tags reads as all-cold. Forms and mapped-pipeline opportunities can also seed members (forms_enabled / opportunities_enabled).
  • Stage-tag mirroring — every stage change writes <stage_tag_prefix><stage> and removes every other stage tag. FunnelStageService.syncStageTag() is awaitable and serialized per contact via _tag_chains (f9f67b101): two moves in one tick used to interleave remove/add and leave a contact carrying two stage tags. The reconcile add_tag and the move fallback write through it.
  • Pipeline mappingpipeline_mapping maps GHL pipeline/stage → funnel layer; the reconcile job compares behaviour stage vs mapped location and queues mismatches for owner approval (knox_funnel_reconcile in chat, or the dashboard Reconciliation panel). movePipeline falls back to a tag (mode: 'tag_fallback') when GHL scopes refuse the write.
  • Outbound sends go through NoxGhlActionService.checkOutboundAllowed (actions.frequency_cap, default 2 sends / 7 days / channel).
  • Scan report — the reconcile/manual sync persists what it did (scanned / seeded / skipped-unmapped / mismatches / truncated) to feeding.last_sync_summary, surfaced on the settings page (console output is unviewable in prod).
  • Rate-limit resiliencewithGhlBackoff on searchOpportunities (a swallowed 429 read as a short page and silently truncated the board scan), stage-tag sync writes, and hydrateIdentity.getContact; HighLevel SDK clients use logLevel 'none' (the SDK dumped bearer-token prefixes on handled errors).
  • Deleted-contact self-cleanup — a definitive 400 "Contact not found" nulls the kk_token's ghl_contact_id and deletes the funnel doc only when cold with zero signals; audited ghl_deleted_cleanup.
  • Backfill fixes — seeds only Agent-K-pushed kk_tokens (external ManyReach mints are not a funnel source); reconcile pipeline scan cap 10 → 200 pages (below-board-size cap re-read the same first 1,000 opportunities forever); ensureContact(hydrate:false) for bulk seeds (was a 7k-call GHL 429 storm — the sweep's paced _hydrateUnnamed repairs names instead).

Gating

Super-admin company.nox_funnel_beta_enabled (toggle: PUT /company/:_id/nox-funnel-beta, also drops knox.daily_brief.send_hour 9→5) outranks tenant nox_funnel.enabled. FunnelStageService.getConfig() returns null without the beta flag — fully dark, no writes, no audit.

Endpoints

  • Tenant (app/routes/noxFunnelRoutes.js): GET /knox/funnel/overview, /contacts, /call-prep, /contacts/:id/journey, /contacts/:id/audit, /reconciliation; POST /contacts/mark-won, /contacts/move-stage, /reconciliation/apply, /reconciliation/:id/dismiss.
  • Settings (CompanyRoutes.jsNoxFunnelSettingsController): GET/PATCH /company/:id/nox-funnel, POST …/nox-funnel/sync, POST …/nox-funnel/reset (danger zone — deletes funnel contacts, recon items, daily stats + clears learned; audit logs kept, incl. a funnel_reset row; feeds untouched — backfill/sync rebuilds), GET …/nox-funnel/ghl-options.
  • Admin (adminNoxFunnelRoutes.js): GET /admin/nox-funnel/audit-logs, /contacts/trace; POST /backfill, /brief-send-hour-backfill, /reason-text-backfill.
  • Internal ms-ai → backend (internalKnoxRoutes.js): GET /internal/knox/funnel/{contact,summary,stats,priorities,reconciliation}, POST …/actions/{tag,move,trigger-workflow,book-meeting,mark-customer}, …/reconciliation/apply, …/conversation-outcome, …/suggestion-decisions; brief: GET /internal/knox/brief/{snapshot-data,sections,offers}, POST …/offers/:id/resolve.

ms-ai side

  • knox_funnel read tool (tools/knox-funnel.tool.ts) — kinds summary | priorities | stats | contact; the source of truth for lead-state questions on funnel tenants. Honesty-gated: not_enabled → fall back to knox_stats and don't mention the funnel.
  • Action tools (tools/knox-funnel-actions.tool.ts): knox_tag_contact, knox_move_pipeline (auto), knox_trigger_workflow (confirm per workflow_trigger_mode), knox_book_meeting (hard confirm), knox_funnel_reconcile (list/apply), knox_mark_customer (→ won). All honesty-gated — ok:false means it did NOT happen.
  • Brief: deterministic funnel blocks from services/knox-funnel-insights.ts (10 blocks, min-sample guards) assembled by knox-brief-snapshot.service.ts; knox_brief_offers grounds "yes, do the first one" against persisted brief_offers and executes via the mapped real tool. Conversation outcomes are classified in the fact-extraction pass (confidence ≥ 0.7) and posted to /internal/knox/funnel/conversation-outcome.
  • Math/priorities are computed in backend: NoxFunnelMathService.js (deterministic rates/velocity/bottleneck/pacing over funnel_daily_stats, rates suppressed under min-sample — never faked) and NoxFunnelPriorityService.js (explainable scoring, every component emits a human reason; no ML/LLM).

Frontend

  • Board: views/funnel/Index.vue + views/funnel/components/ (drag-to-move FunnelBoard.vue, CallPrepPanel.vue, ReconciliationPanel.vue; stage-list search, stage drill-down StageContactsModal.vue, signal drill-down refs + call deep links, unified contact Details modal ContactDetailModal.vue with a mark-won button); /funnel redirects to /dashboard?tab=funnel.
  • Tenant settings: views/settings/nox-funnel/Index.vue (/settings/nox-funnel) — conversation-outcome toggles + chat threshold, web-visitor feeding toggle, Customer (won) mapping target, last-scan summary, danger-zone Reset funnel (ConfirmDialog → POST …/nox-funnel/reset).
  • Admin: beta toggle in views/admin/companies/Detail.vue; audit browser views/admin/nox-funnel/{AuditLogs,ContactTrace}.vue.