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 | a 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) |
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.* (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 false — NoxFunnelReconcileJob._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)
| 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 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 reconcileadd_tagand the move fallback write through it. - 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). - 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 resilience —
withGhlBackoffonsearchOpportunities(a swallowed 429 read as a short page and silently truncated the board scan), stage-tag sync writes, andhydrateIdentity.getContact; HighLevel SDK clients uselogLevel '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_idand deletes the funnel doc only when cold with zero signals; auditedghl_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_hydrateUnnamedrepairs 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.js→NoxFunnelSettingsController):GET/PATCH /company/:id/nox-funnel,POST …/nox-funnel/sync,POST …/nox-funnel/reset(danger zone — deletes funnel contacts, recon items, daily stats + clearslearned; audit logs kept, incl. afunnel_resetrow; 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_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; stage-list search, stage drill-downStageContactsModal.vue, signal drill-down refs + call deep links, unified contact Details modalContactDetailModal.vuewith a mark-won button);/funnelredirects 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 browserviews/admin/nox-funnel/{AuditLogs,ContactTrace}.vue.