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-agent | Handles |
|---|---|
| Navigation | "where do I find X?" UI guidance |
| Analytics | visitor/session/call metrics |
| Knowledge | product docs, how-tos, troubleshooting (RAG) |
| Action | side effects (send SMS, place call, book) |
| Outreach | draft follow-up emails/SMS as documents |
| Generalist | fallback 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 only —DailyBriefEmailJob,KnoxBaselineMetricsJob,RecordingBackfillSweeperJob,MsAiHandoffSweeperJob,KnoxGhlSyncJob.KnoxStatsControllerPLAN_GATED—live_sessions,recorded_sessions,ai_chatbot,auto_connect;FEATURE_COMPANY_FIELDSfeeds 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._senddeterministically 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_atonuser_sessions.bookings[]/person_profiles), so Nox stops reporting them; reschedules sync byghl_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 backendop),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 carrylongText(forcestext_edits) andvoiceCodec: '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 (19blocked, 9invoke, ~28set). The header comment's "211 operations" is the portal surface, not the registry size. - Backend owns field truth.
catalog-resolver.tsresolves everysetop againstbackend-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 withnpm run nox:catalog:sync; at runtimeGET /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 astrongflag 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 apreview_tokenfrom 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/blockedrefused in code. Backend applies a field-level CAS — every change carries thebeforeit was previewed against; mismatch → 409conflicts[]→ the tool reportsstatus: 'conflict'("Someone changed this setting while you were confirming"). Effective values are read back from the DB, never echoed;partial,mismatched, andsync_pendingare distinct honesty states, andfeature_disabledsurfaces as itself (0da8bf7ee). - Revert + audit:
revert_audit_idreads the priorknox_action_auditrow and re-applies its storedbefore.auditCritical(243adfcba) makes audit writes best-effort except forknox_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.jsis an exact port of the frontendmemberHasPermissionover 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 too —readandnavigateHintrefuse without the flag._authorizealso checkscompany.nox_config_enabled.ai_agent_authentication*keys sit innoxConfigFieldMap.jsFORBIDDEN_PREFIXESso Nox can never write them. - Owner notification —
_notifyOwnerOfMemberChangefires a low-priority in-app "Nox changed a setting" tocompany.user_idwhen a non-owner drove the write; fire-and-forget. - Navigation hints —
POST /internal/nox/config/navigate-hint, socket-only to the acting user's own sessions; the destination is deterministic fromnavigate-routes.ts(routeForOp: opnavigateTo→ op sub-page → domain landing), never model-authored. Apply emits a hint too, because the prompt calls describe once per request. Frontend listens onnox-config-applied,nox-user-config-applied,nox-config-navigate(AppLayout.vue), ignores same-page hints, and validates the path. - Card contract —
action_type: 'nox_config'→KnoxConfigCard.vue: per-fieldbefore → after(never raw JSON), toned applied / blocked / conflict, explicit copy forsync_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).
- Agent —
agents/knox-standup.agent.ts, routed bydto.mode === 'standup'and never by intent (handles = ['*']); allowlistSTANDUP_TOOL_NAMES(standup tools + person lookup, crm, visitor profile, list scheduled, brief offers, visitor watch),maxSteps: 10,maxOutputTokens: 400. - Run identity —
runIdFor()=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 inknox_agent_conversations,utterance_reflinks a task to its clause; reference state (presented list / mentions / last_task / acted) is Redisknox:run-state:with a 2h TTL (services/knox-run-state.store.ts). ScheduledNoxActionModelgrew into the task queue. Kinds:ai_call, sms, email, stage_move, note, enrich, reminder. Statuses:pending_confirm → scheduled → fired → done, pluscancelled,failed,expired, andblocked(a dependency died —blockDependents(id, cause)withdependency_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_runwithoutconfirmedreturns readback data +live_now(the live-visitor exception:run_immediatelyreleases one task alone and kicks the executor but never bypasses arequires_approvalhold); withconfirmed: trueit expires stale tasks and re-runs the outbound gates, returningexpired[]andgate_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.EveningRecapJobruns 17:30 company-local, once a day, scoped to companies with arun_idin the last 36h. - Tenant API (
89679bd7d,NoxActionUserController):BOOKABLE_KINDS= all seven;POST /knox/actions/schedule-callbooks any kind (CRM kinds resolve their contact at booking time),PATCH /knox/actions/scheduled/:idreschedules/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 carryingcall_status/call_outcome/duration),GET /knox/contacts/search,GET /knox/crm/pipelines. Internal:/internal/knox/tasks,/tasks/batch,/tasks/confirm,/tasks/live-now. The frontendScheduleCallDialogsendsai_callaskind: undefined(the backend default).GET /knox/brief(knoxBriefUserRoutes.js, passport) serves the mobile app's native brief.
Nox Live canvas contract
- Per-turn action registry —
KnoxActionRegistry(MAX_PROPOSED_ACTIONS_PER_TURN = 1, dedupe by URL, idsA<n>). Voice keeps seed 1 and round-tripsnextIdthrough its Redis snapshot; chat seeds from a timestamp (fa501aaff) because a fresh registry per turn mintedA1every time — the 2026-09 prod bug where navigation fired once per conversation and never again.beginTurn()clears actions but keepsnextIdmonotonic. - Server-declared cards (
services/knox-canvas.tscanvasForTool()):knox_daily_brief,knox_funnel,knox_crm,knox_recent_bookingsmap to typed kindspriorities,stage_contacts,meetings,pipelines,opportunities; unmapped tools fall back to frontend inference.MAX_ROWS = 8,STAGE_CARD_MAX_ROWS = 10. - Ordinal contract —
stageCardEntries()is the exact rendered slice, andknox_crmregisters 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 byghl_contact_id. - Append-only + promotion —
frontend/src/components/knox/live/canvasOrder.jsupsertInto(): insert new, upsert by key, and a card a live tool call refreshed moves to the end; replays and background polls passpromote: falseso the canvas doesn't rearrange itself while nobody is talking about it. Once-watchers arm withexpires_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 itsNoxReportTokenModelwere removed; no public links). Gated bycompany.nox_activity_enabled. Merges three producers chronologically: Mongolead_lifecycle_events, Mongonox_actions, and ms-ai Postgresknox_action_eventsviaGET /internal/knox/action-events(knox-ghl-webhook.controller.ts;nox_writerows skipped there —nox_actionsis authoritative). - Tenant thread reads on ms-ai:
GET /knox-agent/conversationsandGET /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 proxyGET /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 aserror"Never executed" (it used to persist asok); tool-status labels ship on the signed-URL response. Typed text during a live call goes into the call. - Custom LLM —
KNOX_VOICE_LLM=custom-llmis intentional (Z.ai GLM via the ElevenLabs custom-LLM path, see Telephony);knox-agent-sync.service.tserrors loudly if it'scustom-llmbut 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/ backendccd799a94).
Related subsystems
- Nox Funnel — the behaviour-derived lead funnel Nox reads and acts on (backend-owned engine).
- Agent K — the prospecting agent (GHL push pipeline).