Agent K
Agent K sources prospects and delivers them into the tenant's GoHighLevel.
Two modes (Amendment 03, ai_agent_k.mode):
push(Mode A, default) — contact + custom fields + why-now note + trigger tags; the tenant's own GHL workflows send the sequences.send(Mode B) — Agent K also composes and sends the 3-touch email sequence through GHL itself (AgentKSequenceJob).
LinkedIn keeps an approval queue in both modes. Backend owns the pipeline and
tenant (sell-side) wallet billing; ms-ai does
drafting-adjacent LLM work (ICP parsing, previews, replies, stop-intent) and
records platform cost. Limited beta.
feeders queue & pacing
inbound KnockID (realtime, ─────────► (bypasses queue → push)
PersonResolverService)
AgentKOutboundJob (hourly): ─┐
Apify SoS registrations + ├─► agent_k_prospects ─► AgentKTrickleJob
Apollo search→match reveal │ (pending/drafted/ (5 min tick, working
+ PredictLeads why-now ─┘ skipped ledger) hours only, jittered
AgentKReactivationJob (hourly, 6–20 min gap, burst ≤3,
dormant GHL contacts) daily cap)
│
review feed (auto_push=false, default)
manual "Push to GHL" ── or auto_push=true
│
AgentKPushService.pushLead → GHL contact
(contact created only now — at approval)
kk_token + kk_why_now fields · why-now note
`nextlevel *` trigger tags (tag failure = push failure)
│
mode 'push': tenant's GHL workflows send …?kk={{contact.kk_token}}
mode 'send': Agent K drafts touch 1–3, sends via GHL (AgentKSequenceJob)
│
POST /user-session/kk-click ─► identity stitch + `nextlevel returned-to-site`
Feeders
| Feeder | Trigger | Files |
|---|---|---|
| Inbound KnockID | realtime on verified identify — PersonResolverService._fireAgentKInbound → ms-ai POST /internal/knox/agent-k/inbound | app/services/PersonResolverService.js, ms-ai/src/agent-k/ |
| Outbound net-new | AgentKOutboundJob (hourly; once/day per tenant via ai_agent_k.last_outbound_scan_day claim). Apify SoS filings (per-state actors in app/configs/apify.js; the shared pull runs as company_id: 'platform') + Apollo mixed_people/api_search → people/match reveal (1 credit, ≤15/scan) + PredictLeads signals | app/jobs/AgentKOutboundJob.js, app/services/AgentKOutboundService.js, app/lib/apifySos.js |
| CRM reactivation | AgentKReactivationJob (hourly; once/day per tenant), requires re_engage.revive_crm + GHL | app/jobs/AgentKReactivationJob.js, app/services/ReactivationService.js |
Why-now rule: outbound discovery requires a signal (hiring → funding →
expansion → modernizing); ICP fit alone is a cold list — the prospect is
ledgered skipped with reason no_why_now, with a second-chance re-check
before final skip. New-business registrations carry an unconditional why-now.
Signals are always-on (every lookup runs, cheapest first — cost impact;
watch_for.* no longer gates the lookups). CRM reactivation dormancy is
configurable via re_engage.dormant_days (default 90). Each feeder writes
scan telemetry to ai_agent_k.last_scan (at/queued/reason — "never silent");
skip reasons surface on feed cards and in admin diagnostics.
Queue & pacing
agent_k_prospects (app/models/AgentKProspectModel.js) — status
pending | drafted | skipped, unique (company, source, external_id) doubles
as the dedupe ledger. AgentKTrickleJob (every 5 min) releases only inside
the company's working hours (working_hours + timezone), with a jittered
6–20 min gap, burst 1–3, ranked by AgentKSourceWeightService.scoreProspect.
Daily cap = ai_agent_k.daily_limit (default 10), counted from
lead_lifecycle_events with stage pushed + legacy drafted since local
midnight.
- Review feed —
ai_agent_k.auto_pushdefault false: released leads wait in the feed for a manual "Push to GHL" click;truerestores automatic pushes. Deploy-day scriptscripts/grandfather-agent-k-auto-push.jsgrandfathered existing beta tenants totrue. - Notifications — one in-app notification per lead-finding burst;
AgentKDailyPushJob(hourly tick) sends the single permitted throughput push notification: once/company/day, ~4h after the day's first release, only if ≥1 lead surfaced (notification row is the day-guard).
GHL push (app/services/AgentKPushService.js)
pushLead() order: suppression check (NoxGhlActionService, 72h per-contact
cooldown) → mint kk_token → write custom fields → why-now note
(Agent K — why now: …) → trigger tags → NoxActionModel status
pushed + lifecycle + funnel enterFunnel.
GHL contact creation is gated on approval — no contact exists in GHL until
the lead is pushed (manually from the feed, or auto with auto_push); the
pre-approval nextlevel app-leads tag is dropped.
- Custom fields write by field ID — resolved once per name and cached in
go_high_level.kk_field_ids(Mixed map;go_high_levelis a strict subdoc — cache keys must be schema'd or writes drop silently).kk_token/kk_why_noware fixed;linkedin_url/job_titlenames are tenant-configurable viaai_agent_k.ghl_fields(blank = skip). Native GHL fields (company/website/city/state/country) fill directly. Field and note writes are best-effort. - Tags are the workflow trigger — all prefixed
nextlevel(with space):nextlevel agent-k-lead,nextlevel signal:<type>,nextlevel source:<src>, plusnextlevel returned-to-sitewritten on kk-click. A tag write failure fails the push (reason: 'tag_failed'); everything else is best-effort.
Mode B — Agent K sends (ai_agent_k.mode = 'send')
Agent K composes and sends the email sequence itself through GHL; every touch
still goes through the draft-and-approve queue (per sending_mode).
- Inputs (
ai_agent_k.send.*):offering(the "what"),link_url(where every tracked link points —[LINK]marks placement),touches.{t1,t2,t3}per-touch copy overrides (blank = Agent K drafts fresh per lead), andtouch_modeguide | verbatim(defaultguide,c13e3e403): filled copy is a brief the drafter rewrites per lead;verbatimsends it word for word. Existing Mode B tenants with filled boxes switched toguideon deploy. AgentKSequenceJob(hourly) — touch 1 drafts at push time (AgentKPushService); touches 2–3 draft ~3–4d / ~4–5d after an unanswered send (GAPS_MS,PER_COMPANY_CAP20/tick). Filler guardrail: no copy from ms-ai = touch skipped, never a hollow message.- Daily send cap —
ai_agent_k.daily_send_cap(default 40, UI 10–150). - One auto-send gate (
072feec9c) —NoxGhlActionService.autoSendDecisiondecides every send and records why: rulesapproved,agent_k_auto_send_warm,nox_autonomous,standing_rule;COLD_SOURCES = ['reactivation', 'outbound']. An Agent K message obeysai_agent_k.sending_modeonly —knox.actions.autonomous_sendno longer overrides it. Quiet hours (recipient tz) hold any auto-send.NoxActionModelgainedsend_ruleandsent_by(default''), which power the lead card's send receipt; the rule wording lives frontend-side only. - Sequence read —
GET /knox/actions/sequence?ghl_contact_id=(NoxActionUserController): the 3-touch ladder withnext.due_atderived fromAgentKSequenceJob.GAPS_MSandstopped: ghl_workflow | not_started | replied | complete; Mode A returnsmode: 'push'with an empty ladder. - Feed buckets — a
draftsbucket was added (ce6638c49) and removed again (e41c26e81):reviewis the union of waiting-for-push and waiting-for-approval. No date range now means all-time — the hidden 7-day cap on terminal buckets is gone — and there's asignalsub-filter whose counts come from one$group. - Reply suppression — an inbound reply dismisses pending drafts
(
handleInboundReply); a dismissed touch (human "no") also stops the chain.handleInboundReplytreatsstatus: 'pushed'as outreach too (dec42a228), so Mode A replies to GHL-workflow-sent mail log lifecycle +led_to_reply;sent_atdesc gives a KK-sent touch attribution over the bare push. - Stop-intent classifier —
NoxGhlActionService._classifyStopIntent→ ms-aiPOST /internal/knox/agent-k/classify-reply→AgentKReplyService.classifyStop()(strict JSON{"stop": bool}, temp 0,maxTokens50). Model =AGENT_K_STOP_MODELenv override, else the default classifier model — seed a cost-tracking price row before switching model. Fails openstop=false(a missed opt-out is recoverable, a wrong one is not); usage recorded under agentagent_k.
kk_token & click stitching
kk_tokens(app/models/KkTokenModel.js) — one token per (company, GHL contact), or per email for external campaigns; denormalized email/phone/name so click resolution needs no GHL call. Every GHL template link appends?kk={{contact.kk_token}}— the token lives on the contact.POST /user-session/kk-click(UserSessionController@kkClick): bot filter (client flag → scanner-UA regex → IP check) → token lookup →PersonResolverService.resolvestitches the identity onto the session → writesnextlevel returned-to-site→ funnellink_click/web_visitsignals → widget auto-engage of the returning lead (with lead memory, cooldownknox.visitor_watch.auto_engage_cooldown_hours).- Attribution: kk-click sessions get an Agent K traffic source
(ManyReach campaign clicks get their own source); sessions carry
per-visit arrival sources (the original source is preserved on Agent K
re-stamp); every
kk_tokenmint is stamped with its provenance and attribution follows it. One-time backfill runs as a background job (admin endpoint + poll from the Agent K Maintenance page). - Backfill:
POST/GET /knox/agent-k/kk-backfillstampskk_tokenonto existing GHL contacts (requires the field to already exist; progress persisted atai_agent_k.kk_backfill, socketkk-backfill-progress). The hourlyKkTokenSyncJoband the tenant picker now reuse the same backfill — see Identity Graph › kk_token coverage. - Click → contact stitch (
65908d2da,d1dc0f893): the stitch looks in three homes — session CRM stamp, PersonProfile by(person_id, company_id), and the LeadConnector email-click recipient — becausePersonResolverServicewrites the click's contact onto the profile and never stamps the session back; a background backfill (AdminAgentKController, Agent K Maintenance) repairs click rows written without a contact. - Delivered leads join the identity graph (
ec5b2e36f):AgentKPushService._finalizecallsPersonResolver.recordContact()on every delivered path (neverpending_push); new profiles areoutbound_only.
Campaign tokens & ManyReach
For external senders: POST /knox/agent-k/campaign-tokens mints kk_tokens
rows per email (no GHL contact). ManyReach push
(app/services/ManyReachService.js; routes POST /knox/agent-k/manyreach/{connect,token-field,push},
GET …/campaigns) writes the token into a ManyReach custom field
(ai_agent_k.manyreach.token_field, default custom20; token_field_name is
the display name of the variable in ManyReach — the API pushes by slot);
templates link with ?kk={{<token_field>}}. manyreach.links[]
({field,url,name}) pushes full pre-tokenized URLs per prospect
(<field> = url?kk=<token>) so templates paste a plain variable — works
around ManyReach's editor URL-encoding {{ }} inside hrefs; a link keeps its
field for life so template tags never renumber. UI: the ManyReach card on
the integrations screen (src/components/integrations/ManyReachCard.vue +
ManyReachCampaigns.vue), no longer a dashboard tab.
LinkedIn (Unipile)
app/services/AgentKLinkedInService.js + AgentKLinkedInJob (every 30 min);
sequences in agent_k_linkedin_sequences. Tenant's own connected account id
in ai_agent_k.channels.linkedin.account_id, connected via Unipile hosted
auth (POST /unipile/hosted-auth). Warm-up like → 12–48h wait →
connect request lands in the approval queue (a NoxAction kind
linkedin). Caps enforced at send, not draft: 20 connects / 50 likes /
50 DMs per account/day; acceptance breaker pauses connects under 25%
acceptance over ≥10 invites in 7 days. Webhooks only, never polling
(POST /unipile/webhook). The channel UI is live (the old
LINKEDIN_CHANNEL_UI flag is removed).
Monthly seat billing — AgentKLinkedInRenewalJob (hourly, idempotent per
period): the Unipile seat is a recurring cost, clocked by
channels.linkedin.next_charge_at (set +1 month on connect, advanced on each
renewal, cleared on disconnect — clearing stops billing). Due + wallet covers
→ silent charge (unipile_renewal — its own wallet-statement line, "LinkedIn
monthly"); due + wallet short → immediate disconnect + email (no grace
period); due tomorrow + short → day-before heads-up, once per cycle
(renewal_warned_at). Migrations (MigrationPanel):
/migrations/backfill-agent-k-linkedin-renewal (stamp next_charge_at on
already-connected tenants) and /migrations/run-agent-k-linkedin-renewal.
Billing — tenant SELL side (backend wallet)
Supersedes the old rule "provider usage is priced and billed only in ms-ai". ms-ai still records the platform's COST (margin dashboards, reconciliation); what the tenant pays is now debited backend-side from the wallet at call time.
AgentKWalletService + lib/agentKPricing.js:
- Prices — settings row
agent_k_provider_pricing(super-admin: Admin → Agent K,/admin/agent-k), SELL USD per billable step:apollo(search page or reveal),predictleads(prospect with a why-now signal),apify(new-business filing),unipile(account connected / monthly renewal). Defaults 0 = unpriced steps are free (never invent a charge; a settings read failure also bills free). Distinct from ms-ai's cost-sideagent-k-provider-pricing.ts. 60s in-process price cache. - Debits —
ai_credit_logsrows againstremaining_purchased_ai_credits(the wallet, 1 credit = $0.01 — plan credits never touched), at call time, after a successful provider response (failed calls unbilled). Idempotent onidempotency_key(partial-unique index) so retried scans re-charge nothing. - Gates —
hasBalance()before a scan (never hit a paid API for a tenant who can't pay);canAfford(provider)before single known-price actions (LinkedIn connect) so users see "you need $X" instead of going negative. - Wallet-statement line types:
agent_k_apollo_search/_match,agent_k_predictleads,agent_k_apify_sos,agent_k_unipile_connect,agent_k_unipile_renewal— see Wallet.
Provider usage → platform COST (backend meters, ms-ai prices)
trackProviderUsage()(app/lib/providerUsage.js) — fire-and-forget$inconprovider_usageper (company, provider, day).AgentKUsageEmitJob(hourly) forwards closed-day rows (day < today,emitted != true,company_id != 'platform'— the shared Apify SoS pull is never billed) to ms-aiPOST /api/cost-tracking/internal/provider-usage, idempotent onexternal_id agentk:<co>:<provider>:<day>; theemittedwatermark makes retries safe.- ms-ai prices via
src/cost-tracking/agent-k-provider-pricing.ts(USD/call:apollo_match0.05,predictleads0.02,unipile0.01,apollo_searchfree; JSON env overrideAGENT_K_PROVIDER_USD, no deploy) into ausage_charge. Those credits flow into the existingtoken-usagesum and the hourlyAITokensCreditJobdeducts them. This path is the platform-cost ledger; the tenant's sell price lives inlib/agentKPricing.js(above). Reconciliation/statements: ms-ai cost-tracking admin endpoints (see Cost Tracking).
Learning & steering
- Skip-demotion (
ms-ai/src/agent-k/agent-k-learning.service.ts): asignal_typewith ≥5 outcomes and ≥60% skips is demoted — unless it ever converted (reply/booking). Demote-only; never adds volume. Fed byGET /internal/knox/agent-k/signal-stats. knox_agent_k_weightsNox tool (ms-ai) →POST /internal/knox/actions/agent-k-weights→ai_agent_k.source_weight_overrides(multiplier clamped 0.25–3) consumed byAgentKSourceWeightService— rank-only, never volume.AgentKWeeklyDigestJob(hourly tick; sends onai_agent_k.digest.dayin company tz, once per ISO week viadigest_last_sent_week), agency-branded; empty weeks send nothing.
Flags & config
Super-admin company.agent_k_beta_enabled outranks tenant
ai_agent_k.enabled; both are required by every job and by
NoxActionUserController._resolveAgentKCompany (403 otherwise). Toggle:
PUT /company/:_id/agent-k-beta. Tenant config under ai_agent_k.*:
mode (push/send), auto_push, send.{offering,link_url,touches},
daily_send_cap, icp_description/icp_filter (parsed by ms-ai "Let Agent K
read this"), last_scan (telemetry, Mixed), watch_for.*, re_engage.*
(incl. dormant_days), skip_self_reachouts, daily_limit, ghl_fields,
manyreach.{api_key,token_field,token_field_name,links[]}, template.*,
channels.* (incl. linkedin.{connected_at,next_charge_at,renewal_warned_at}),
sending_mode, source_weight_overrides.
Endpoints & surfaces
- Tenant (
app/routes/noxActionRoutes.js):/knox/agent-k/parse-icp,suggest-reply,preview-message,lifecycle-summary,report-details,dormant-count,linkedin/{connect-link,disconnect},kk-backfill,campaign-tokens,manyreach/*(incl.links,disconnect),reactivate; plus the shared/knox/actions/*review queue (also the manual "Push to GHL" path for theauto_push=falsefeed). - Internal ms-ai → backend (
internalKnoxRoutes.js):POST /internal/knox/agent-k/push,GET …/agent-k/signal-stats,GET …/provider-usage,POST …/actions/agent-k-weights. - ms-ai (
src/agent-k/agent-k.controller.ts):POST /internal/knox/agent-k/{inbound,suggest-reply,classify-reply,draft-reengage,draft-outbound,preview-message,parse-icp}. - Frontend: standalone
/agent-kpage (views/agentk/Index.vue, Feed + Reports). The Feed is a three-pane inbox since1e6159da—AgentKInbox.vue(rail / lead / contact, resizable,AgentKQueue.vuedeleted),AgentKRail.vue(tabs To review / Sent / Skipped / All, Channel + Why-now signal selects, search, All-dates range),AgentKLead.vue(enrichment,AgentKSequence.vueladder,AgentKThread.vuelive GHL thread, send receipt, safety-gate card, push chips), confirm dialogs before push/send/skip; tests inAgentKInbox.test.js/AgentKPanes.test.js. Report tiles drill into the lead list. Settingsviews/settings/ai/agents/AgentK/index.vue— 7 tabs (Overview, Ideal customer, Your traffic, Outreach, Channels, Delivery, Do-not-contact suppression list), one<Form>spanning all (v-show, never v-if — unmounted vee-validate fields drop from submit). Standalone setup route/settings/ai/agents/agent-k/setup(Setup.vue) — removed from the onboarding wizard;PricingModal.vueshows per-step prices before enabling. Admin: beta toggle inviews/admin/companies/Detail.vue; pricing/admin/agent-k(views/admin/agent-k/Settings.vue); Agent K Maintenance/admin/agent-k/maintenance(views/admin/AgentKMaintenance.vue— attribution-backfill panel with job polling, legacykk_tokensindex fix, LinkedIn draft repair).
Living plan: AGENT_K_PLAN.md (repo root, untracked) — code is authoritative
where they disagree (tag prefix, field-id cache location; note Amendment 03
re-introduced Agent K email sending as opt-in Mode B).