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
| Stage | How it's earned |
|---|---|
cold | entered the funnel (Agent K push, feed tag, form, opportunity seed) |
intermediate | first visit/open/click, opens ≥ email_open_threshold (3), answered call, reply, chat, score_bands.intermediate |
warm | visits ≥ 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) |
booked | any booking |
won | manual/terminal only — moveToWon; 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)
| Collection | Model | Notes |
|---|---|---|
funnel_contacts | app/models/FunnelContactModel.js | one 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_logs | NoxFunnelAuditLogModel.js | every decision (dedupes, gates, flips, intercepts) — TTL 120 days (live deployments need a manual collMod) |
funnel_daily_stats | FunnelDailyStatsModel.js | per (company, local day) census/transitions/dwell — the math engine's input |
funnel_reconciliation_items | NoxFunnelReconItemModel.js | behaviour stage vs CRM layer mismatches, deduped review queue |
brief_offers | BriefOfferModel.js | executable brief offers, valid_until ≈ 48h (read-time gate, no TTL delete) |
lead_lifecycle_events | LeadLifecycleEventModel.js | append-only lifecycle (stage_changed, showed, closed_won, …) |
Config lives in company.nox_funnel.* (strict subdoc — undeclared keys drop
silently): thresholds.*, score_bands, demotion.* (90/60/14/14 days),
feeding.{tags, tag_rules[{tag,stage}], forms_enabled, opportunities_enabled},
high_intent_pages, instant_warm_signals, stage_tag_prefix (default
nextlevel stage:, blank = off), goals.monthly_booked_target,
pipeline_mapping.pipelines[{pipeline_id,stage_id,layer}],
actions.{workflow_trigger_mode, frequency_cap, standing_rules},
intercept.enabled, learned.{thresholds,history} (self-learning).
Jobs (backend SchedulerService)
| Job | Schedule | Does |
|---|---|---|
FunnelSweepJob | every 10 min | folds session analyses / calls / chats into recordSignal (20-min lookback, dedupe-safe) |
FunnelDailyStatsJob | hourly (once per local day) | yesterday's census rollup into funnel_daily_stats |
KnoxGhlSyncJob | 03:00 UTC | (pre-existing) GHL sync the funnel jobs depend on |
FunnelDormancyJob | 04:00 UTC | step-down demotion, very-warm hold expiry, reactivation flagging |
NoxFunnelReconcileJob | 04:30 UTC | mapped-pipeline scan → recon queue; feed-tag scan; closed-won/lost polling |
FunnelBackfillJob | 05:00 UTC + manual | idempotent history backfill after gating a tenant (kk_tokens → lifecycle replay → bookings) |
NoxFunnelLearningJob | Mon 05:00 UTC | demote-only self-learning: repeated rejections (n≥5, ≥60%) raise a threshold one notch into nox_funnel.learned |
DailyBriefEmailJob | every 10 min tick | sends 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_rulestag enter at that stage (upward-only against an earned stage); legacy flatfeeding.tagsreads 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 stale stage tags (fire-and-forget). - Pipeline mapping —
pipeline_mappingmaps GHL pipeline/stage → funnel layer; the reconcile job compares behaviour stage vs mapped location and queues mismatches for owner approval (knox_funnel_reconcilein chat, or the dashboard Reconciliation panel).movePipelinefalls 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).
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.js→NoxFunnelSettingsController):GET/PATCH /company/:id/nox-funnel,POST …/nox-funnel/sync,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_funnelread tool (tools/knox-funnel.tool.ts) — kindssummary | priorities | stats | contact; the source of truth for lead-state questions on funnel tenants. Honesty-gated:not_enabled→ fall back toknox_statsand don't mention the funnel.- Action tools (
tools/knox-funnel-actions.tool.ts):knox_tag_contact,knox_move_pipeline(auto),knox_trigger_workflow(confirm perworkflow_trigger_mode),knox_book_meeting(hard confirm),knox_funnel_reconcile(list/apply),knox_mark_customer(→ won). All honesty-gated —ok:falsemeans it did NOT happen. - Brief: deterministic funnel blocks from
services/knox-funnel-insights.ts(10 blocks, min-sample guards) assembled byknox-brief-snapshot.service.ts;knox_brief_offersgrounds "yes, do the first one" against persistedbrief_offersand 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 overfunnel_daily_stats, rates suppressed under min-sample — never faked) andNoxFunnelPriorityService.js(explainable scoring, every component emits a human reason; no ML/LLM).
Frontend
- Board:
views/funnel/Index.vue+views/funnel/components/(drag-to-moveFunnelBoard.vue,CallPrepPanel.vue,ReconciliationPanel.vue);/funnelredirects to/dashboard?tab=funnel. - Tenant settings:
views/settings/nox-funnel/Index.vue(/settings/nox-funnel). - Admin: beta toggle in
views/admin/companies/Detail.vue; audit browserviews/admin/nox-funnel/{AuditLogs,ContactTrace}.vue.