Skip to main content

Nox / Knox Admin Assistant

Nox (internally Knox) is the AI assistant the customer's team uses inside the dashboard: it answers questions about visitors, sessions, calls, and bookings, and takes actions in connected CRMs. It lives in ms-ai's src/knox-agent/ module, with surfaces in frontend (nox* stores) and sync paths in backend (app/lib/msAiKnoxClient.js, Knox cron jobs).

One insight engine. "What Matters Today" / the insight engine is the single source for all Nox surfaces (daily brief, alerts, etc.) — never re-derive per-surface.

Request flow

askQuestion(dto, auth)
→ hydrate prior turns from DB (source of truth)
→ intent classifier (navigate | analytics | how_to | explain | compare
| troubleshoot | action | draft_followup | fallback)
→ dispatch to a specialist sub-agent
→ run tools (grounded in RAG + memory + live data)
→ persist conversation + usage telemetry

knox-agent.service.ts orchestrates; intent/knox-intent-classifier.service.ts routes (and asks a clarifying question rather than guessing when ambiguous).

Sub-agents

Sub-agentHandles
Navigation"where do I find X?" UI guidance
Analyticsvisitor/session/call metrics
Knowledgeproduct docs, how-tos, troubleshooting (RAG)
Actionside effects (send SMS, place call, book)
Outreachdraft follow-up emails/SMS as documents
Generalistfallback with the full toolset

Gating (2026-08-20 onward)

Nox is on for every company. 4a0c3e103/frontend 3df9a70a deleted the super-admin "Nox Agent" and "Nox Standup Beta" toggles, nox_standup_enabled, the knox.enabled access gates in KnoxCrmController / KnoxStatsController._loadCompany (403 knox_disabled), KnoxNotificationsController, the ChatController LLM enrichment gate, and the toggleNoxStandup / KnoxSettingsController enable-disable endpoints. ms-ai (e14196817) stopped refusing mode: 'standup' per company.

What still gates:

  • knox.enabled (default true, no UI writes it) scopes background jobs onlyDailyBriefEmailJob, KnoxBaselineMetricsJob, RecordingBackfillSweeperJob, MsAiHandoffSweeperJob, KnoxGhlSyncJob.
  • KnoxStatsController PLAN_GATEDlive_sessions, recorded_sessions, ai_chatbot, auto_connect; FEATURE_COMPANY_FIELDS feeds feature awareness (df3f1f841 / ee28aa341): Nox is told what the tenant has, and refuses unavailable features with the real remedy (plan vs account manager) while entitled-but-unconfigured ones stay available with the missing step.
  • company.nox_config_enabled — super-admin, default false — gates settings-by-chat only.

Tools

57 tools in knox-agent/tools/ (registered in knox-tool-registry.ts; was 45 before 2026-08-11), including: knox_visitor_search / knox_visitor_profile / knox_visitor_bookings, knox_recent_sessions / knox_session_analysis / knox_stats, knox_call_transcripts (ElevenLabs audio), knox_chat_transcript, knox_knowledge_search (RAG), knox_place_call (with scheduled_at — max 30 days out, quiet hours defer to next 9am local, fired by ScheduledNoxActionJob every 5 min) / knox_schedule_sms / knox_list_scheduled_actions (renamed from the transient knox_list_scheduled_calls) / knox_draft_followup / knox_send_followup, knox_engage_visitor (message a LIVE visitor's widget now — confirmed gate, dry_run, delivery receipt via ms-comm relay + backend POST /internal/engage-receipt), knox_person_lookup (surfaces CRM-only contacts flagged crm_only; result rows carry pid:<person_id>; fuzzy name matching tolerates one substituted character for STT mishears — backend 864d3c0ae) / knox_enrich_contact, knox_is_visitor_live / knox_presence (live presence from ms-sessions), knox_crm (GHL contact/ opportunity sync), knox_visitor_watch (consolidated notify/engage/list/cancel), the Nox funnel family (knox_funnel, knox_tag_contact, knox_move_pipeline, knox_trigger_workflow, knox_book_meeting, knox_mark_customer (→ manual terminal Won stage), knox_funnel_reconcile, knox_brief_offers (offer registry, ~48h validity)), Agent K's knox_agent_k_weights and knox_agent_k_pipeline (queue counts / pending by source and signal / released today — GET /internal/knox/agent-k/pipeline), and the families added this window: config (knox_config_describe / knox_config_apply / knox_config_invoke), standup (knox_standup_tasks, knox_confirm_run, knox_cancel_task, knox_approve_task, knox_resolve_reference), knox_visitor_forms (/internal/knox/people/forms — real form submissions, so "did X fill in a form?" is never a guess), knox_call_status (/internal/knox/calls/status — added after an optimistic queued hid a failed dial; the same commit fixed phone-resolution precedence for call/SMS tools), and knox_discard_followup. knox_schedule_sms gained a GHL route for tenants with no SMS agent (exact text only, feature-gate checked; 8e53d7f4b / backend 68cd08b25). knox_person_lookup defers its full-text scans to a total miss and the backend bounds chat-mention scans (51f48cd84 / e7fd5ca32). Executions are collected by KnoxToolCallCollector and written to knox_action_events / knox_action_audit. Tool results return structured objects for the canvas/citations, but the LLM reads curated markdown (services/knox-tool-markdown.ts) — with custom renderers for transcript/session/facts/action-feed tools, because the generic renderer only walked scalar fields and silently dropped nested messages[] / conversations[] / facts[]; the per-turn model_markdown is persisted and shown in the admin conversation viewer ("Model saw").

Memory

  • Admin memory (knox-admin-memory.service.ts) — operator insights and standing rules stored as pgvector chunks (knox_admin_memory), pulled into each turn for context.
  • Memory extraction (knox-memory-extraction.service.ts) — fire-and-forget extraction of durable facts from operator questions.

Actions in GHL (the action layer)

Nox can draft-and-approve follow-ups and enrich contacts in GHL. This shipped in two stages: a backend API + central review surface, and an inline-chat approval card (via an ms-ai nox_followup action). A standing-rule / autonomous-send toggle lets some drafts send without per-item approval. Sync is handled by knox-ghl-sync.service.ts with a webhook receiver (knox-ghl-webhook.controller.ts) and nightly KnoxGhlSyncJob (03:00) in backend.

  • Email signature — drafts end on the last sentence (no hand-written sign-off from the LLM); backend NoxGhlActionService._send deterministically appends the tenant signature (rich HTML from the Quill editor, or legacy plain text) if the body doesn't already contain it. The setting moved from Agent K settings to Nox settings (views/settings/nox/Index.vue, email_signature).
  • Cancelled GHL appointments — cancels are mirrored onto the booking copies (status: 'cancelled' + cancelled_at on user_sessions.bookings[] / person_profiles), so Nox stops reporting them; reschedules sync by ghl_appointment_id. Requires the AppointmentUpdate/Delete webhook subscription — verify it on the GHL app.

Settings-by-chat (Nox config)

Nox can read and change tenant settings. The registry lives in ms-ai src/knox-agent/config-registry/; the truth about fields lives in backend.

  • Operation shape (config-operation.types.ts): id (must equal the backend op), kind (set | invoke | read | blocked), domain (14 values), label, aliases[] (the retrieval surface), fields[], riskTier (safe | confirm | human_only), voiceSafe, reversible, requiredEntitlement, requiredRole, blockedReason / navigateTo, requiresSync. Fields carry longText (forces text_edits) and voiceCodec: 'schedule' — the delta codec for working hours ("Saturday off; Monday 08:00-18:00": unmentioned days keep their hours, the server merges). 56 ops across 12 files (19 blocked, 9 invoke, ~28 set). The header comment's "211 operations" is the portal surface, not the registry size.
  • Backend owns field truth. catalog-resolver.ts resolves every set op against backend-catalog.json (93 company + 13 user fields over 30 ops). A declared field the backend doesn't whitelist fails boot; a backend field nobody declared is included as-is; an undeclared backend op is auto-registered voice-unsafe/confirm. Regenerate the snapshot with npm run nox:catalog:sync; at runtime GET /internal/nox/config/catalog (internalNoxConfigRoutes.js) is polled every 5 min and a failed refresh keeps the current registry.
  • Retrieval is lexical on purpose (search()): IDF-weighted tokens, alias containment bonus, an evidence gate (≥2 distinct tokens, a unique token, or a verbatim alias) and a strong flag that turns weak matches into "did you mean?". Possessive pronouns are not stopwords — they disambiguate org vs user scope.
  • Apply flow (knox-config-apply.tool.ts): the op must exist; a real apply needs a preview_token from a dry run bound to (company, op, exact values), single-use and Redis-backed (knox:config:preview:<token>, 2c422a9d0) so it redeems on any pod; human_only / blocked refused in code. Backend applies a field-level CAS — every change carries the before it was previewed against; mismatch → 409 conflicts[] → the tool reports status: 'conflict' ("Someone changed this setting while you were confirming"). Effective values are read back from the DB, never echoed; partial, mismatched, and sync_pending are distinct honesty states, and feature_disabled surfaces as itself (0da8bf7ee).
  • Revert + audit: revert_audit_id reads the prior knox_action_audit row and re-applies its stored before. auditCritical (243adfcba) makes audit writes best-effort except for knox_config_apply, whose row is load-bearing — without it the admin gets an undo that can't work. suppressed_reason: 'duplicate' | 'concurrent' was added and dedup lookups degrade to the Redis claim on DB failure.
  • Authorization mirrors the portal, not a role hierarchy. backend/app/lib/noxConfigPermissions.js is an exact port of the frontend memberHasPermission over the 16 member flags, with a domain→flag table (org/funnel/onboarding → settings.organization, widget → settings.widget, calls → settings.calls, knowledge/agents → settings.ai, integrations → settings.integrations, access → users.update) and per-op overrides for team/branch create/delete. Flags gate reads tooread and navigateHint refuse without the flag. _authorize also checks company.nox_config_enabled. ai_agent_authentication* keys sit in noxConfigFieldMap.js FORBIDDEN_PREFIXES so Nox can never write them.
  • Owner notification_notifyOwnerOfMemberChange fires a low-priority in-app "Nox changed a setting" to company.user_id when a non-owner drove the write; fire-and-forget.
  • Navigation hintsPOST /internal/nox/config/navigate-hint, socket-only to the acting user's own sessions; the destination is deterministic from navigate-routes.ts (routeForOp: op navigateTo → op sub-page → domain landing), never model-authored. Apply emits a hint too, because the prompt calls describe once per request. Frontend listens on nox-config-applied, nox-user-config-applied, nox-config-navigate (AppLayout.vue), ignores same-page hints, and validates the path.
  • Card contractaction_type: 'nox_config'KnoxConfigCard.vue: per-field before → after (never raw JSON), toned applied / blocked / conflict, explicit copy for sync_pending, partial (+ mismatched[]).

Morning Standup (P9) and the task lifecycle

Plan docs: NOX_STANDUP_PLAN.md, nox-standup-scope-v0.2.md, NOX_LIVE_CANVAS_PLAN.md (repo root).

  • Agentagents/knox-standup.agent.ts, routed by dto.mode === 'standup' and never by intent (handles = ['*']); allowlist STANDUP_TOOL_NAMES (standup tools + person lookup, crm, visitor profile, list scheduled, brief offers, visitor watch), maxSteps: 10, maxOutputTokens: 400.
  • Run identityrunIdFor() = standup:<company>:<admin>:<YYYY-MM-DD in admin tz>: one run per admin per local day across chat and voice, deliberately not thread-keyed (turn 1 of a fresh thread has no conversation_id, which split runs and wiped the canvas list). No run table — transcripts stay in knox_agent_conversations, utterance_ref links a task to its clause; reference state (presented list / mentions / last_task / acted) is Redis knox:run-state: with a 2h TTL (services/knox-run-state.store.ts).
  • ScheduledNoxActionModel grew into the task queue. Kinds: ai_call, sms, email, stage_move, note, enrich, reminder. Statuses: pending_confirm → scheduled → fired → done, plus cancelled, failed, expired, and blocked (a dependency died — blockDependents(id, cause) with dependency_failed | cancelled | expired). Standup fields: run_id, depends_on[], condition {if_no_reply_by}, attempts/max_attempts, created_from, utterance_ref, result, override, advisory {reason, overridable}, requires_approval, expires_at.
  • Draft-then-execute (cb251f62b, 021fc0846): bodies exist before the go; time-windowed irreversible tasks die 4h past their window; a second instruction for the same run+person+channel merges into one task and the merge must be stated at readback. knox_confirm_run without confirmed returns readback data + live_now (the live-visitor exception: run_immediately releases one task alone and kicks the executor but never bypasses a requires_approval hold); with confirmed: true it expires stale tasks and re-runs the outbound gates, returning expired[] and gate_blocked[] rather than failing silently later. StandupRunLifecycleJob (10-min tick) sends one "still waiting on your go" ping per run at 60 min and expires at company-local end of day. EveningRecapJob runs 17:30 company-local, once a day, scoped to companies with a run_id in the last 36h.
  • Tenant API (89679bd7d, NoxActionUserController): BOOKABLE_KINDS = all seven; POST /knox/actions/schedule-call books any kind (CRM kinds resolve their contact at booking time), PATCH /knox/actions/scheduled/:id reschedules/rewords/retargets (status filter is the guard), POST …/:id/cancel, POST …/:id/approve (releases a held task), GET /knox/actions/scheduled (+ by-call-sids, now carrying call_status / call_outcome / duration), GET /knox/contacts/search, GET /knox/crm/pipelines. Internal: /internal/knox/tasks, /tasks/batch, /tasks/confirm, /tasks/live-now. The frontend ScheduleCallDialog sends ai_call as kind: undefined (the backend default). GET /knox/brief (knoxBriefUserRoutes.js, passport) serves the mobile app's native brief.

Nox Live canvas contract

  • Per-turn action registryKnoxActionRegistry (MAX_PROPOSED_ACTIONS_PER_TURN = 1, dedupe by URL, ids A<n>). Voice keeps seed 1 and round-trips nextId through its Redis snapshot; chat seeds from a timestamp (fa501aaff) because a fresh registry per turn minted A1 every time — the 2026-09 prod bug where navigation fired once per conversation and never again. beginTurn() clears actions but keeps nextId monotonic.
  • Server-declared cards (services/knox-canvas.ts canvasForTool()): knox_daily_brief, knox_funnel, knox_crm, knox_recent_bookings map to typed kinds priorities, stage_contacts, meetings, pipelines, opportunities; unmapped tools fall back to frontend inference. MAX_ROWS = 8, STAGE_CARD_MAX_ROWS = 10.
  • Ordinal contractstageCardEntries() is the exact rendered slice, and knox_crm registers that same slice as the run's presented list (skipped for single-contact lookups), so "call 1 and 3" resolves against what the admin sees; CRM rows key run-state identity by ghl_contact_id.
  • Append-only + promotionfrontend/src/components/knox/live/canvasOrder.js upsertInto(): insert new, upsert by key, and a card a live tool call refreshed moves to the end; replays and background polls pass promote: false so the canvas doesn't rearrange itself while nobody is talking about it. Once-watchers arm with expires_at = the admin's next local midnight (tzDayWindow); recurring watchers stand.

Activity history & threads

  • Backend NoxActivityController (app/routes/noxActivityRoutes.js): GET /nox/activity, …/export (CSV), …/report (JWT-guarded data for the client-rendered/printed report — the tokenized public share link and its NoxReportTokenModel were removed; no public links). Gated by company.nox_activity_enabled. Merges three producers chronologically: Mongo lead_lifecycle_events, Mongo nox_actions, and ms-ai Postgres knox_action_events via GET /internal/knox/action-events (knox-ghl-webhook.controller.ts; nox_write rows skipped there — nox_actions is authoritative).
  • Tenant thread reads on ms-ai: GET /knox-agent/conversations and GET /knox-agent/conversations/:threadId.
  • Session narration: ms-ai GET /internal/recording/narration (recording-api.controller.ts; narration prose lives only in ms-ai Postgres) + backend proxy GET /user-session/:_id/narration.

Insight engine & daily brief

knox-insight-engine.service.ts produces proactive insights (behavior patterns, opportunities, visitor watch) and feeds knox-brief-snapshot.service.ts / knox-daily-brief-composer.service.ts. Backend's DailyBriefEmailJob (10-min tick) renders and sends the email once per local day at knox.daily_brief.send_hour (default 9; funnel tenants set 5) — it replaced ReportingEmailJob, which is now unscheduled (and the monthly report defaults off for new companies). Funnel tenants get deterministic funnel blocks (knox-funnel-insights.ts) plus executable offers persisted to brief_offers (~48h validity, actioned via knox_brief_offers) — see Nox Funnel. KnoxBaselineMetricsJob (02:00) refreshes cohort metrics. The dashboard briefing strip was removed — the brief lives on the Nox surfaces (badge + email), and the per-user auto-play preference gates spoken playback. The engine also emits conversation-signal cards (rank 2.5, max 2 per brief) from GET /internal/knox/conversation-signals — positive outcomes ("chat captured a lead") as win cards, negative as needs-a-look; knox_aggregate gained the matching recent_conversations metric.

Visitor watches & auto-engage

Watch/auto-engage directives (KnoxVisitorDirectiveModel, backend PersonWatchService) recognize returns by device signals + exact identifiers, dedupe on create, and fire on real page loads only. Tenants list/cancel their own via GET/DELETE /knox/visitor-watches; super-admins get a cross-company view with diagnostics (last_attempt_at / last_skip_reason — admin-only render). Auto-engage delivery goes through ms-communication POST /nox-auto-engage (endpoint auth deferred), which composes and pushes the widget message.

Presence is a Redis heartbeat, not a DB timestamp: backend app/lib/sessionPresence.js reads heartbeat_status:<session> (widget heartbeat, 5-min TTL; falls back to the durable activity_status mirror) — this replaced nox_live_at. 'active' = tab focused (widget will render); 'away' = connected but hidden, present-but-not-deliverable.

Nox Live

Full-screen surface (frontend/src/components/knox/NoxLive.vue, mounted from AppLayout.vue): orb + right-hand canvas. Canvas cards are server-declared (services/knox-canvas.ts maps tool results to _canvas cards) and arrive via tool_result SSE events; for voice calls the events fan out over Redis pub/sub (knox-voice/knox-voice-events.service.ts) because the ElevenLabs tool webhook and the browser's SSE subscription land on different pods. See the canvas contract for ordering and ids. Routes went brand-neutral with the old paths as aliases (/settings/assistant, /settings/funnel, /activity); document.title swaps \bNox\b for the white-label name at the single router guard.

Knowledge grounding

The Knowledge sub-agent and knox-knowledge-search retrieve from the RAG store fed by nox-knowledge-base.

Voice

Nox also has a voice mode (Knox Voice, src/knox-voice/) bridging the same tools into ElevenLabs telephony. A voice call continues the shared Nox conversation (the frontend sends recent turns; knox-voice.service.ts injects a continuity line). Hygiene fixes (2026-07): one spoken acknowledgement per user turn (not one per tool call); spelled-out letters win over the STT rendition when searching names ("N-E-C-O"); post-call persistence strips leaked <think> tags (cleanAgentMessage, incl. orphan </think>); id fabrication is rejected — session/profile tools return an explicit "id may be invented" not-found note and error envelopes are structured so the model reports failure honestly instead of inventing data. White-label: the assistant self-references the tenant's ai_assistant_name and never says "Knock Knock"; agencies can override the TTS voice via agency.nox_voice_id (per-session tts.voiceId override in knox-agent-sync.service.ts; KnoxVoiceService also takes a per-session voice_id). See ms-ai.

2026-08/09 voice hardening:

  • No tool call fails silently (9f31f70e3 + fb249afed, 55cde823c, cd3b9a893): assertVoiceCallable() runs in agent sync over every advertised schema — a nested object / object-array throws the sync instead of shipping an unfillable tool; the standup allowlist is enforced in the tool webhook (KnoxVoiceToolsService.invoke) because one ElevenLabs agent holds every tool; post-call marks a server-side tool with no execution/result/latency as error "Never executed" (it used to persist as ok); tool-status labels ship on the signed-URL response. Typed text during a live call goes into the call.
  • Custom LLMKNOX_VOICE_LLM=custom-llm is intentional (Z.ai GLM via the ElevenLabs custom-LLM path, see Telephony); knox-agent-sync.service.ts errors loudly if it's custom-llm but the agent has no custom-LLM block. Never revert the env; rollback is another enum member.
  • Dictated times resolve in the admin's zone, never as a bare instant (182d8f925); a ringing call is not a failed one and a number already ringing is never redialled (ead1ca7dc / backend ccd799a94).
  • Nox Funnel — the behaviour-derived lead funnel Nox reads and acts on (backend-owned engine).
  • Agent K — the prospecting agent (GHL push pipeline).