Skip to main content

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

FeederTriggerFiles
Inbound KnockIDrealtime on verified identify — PersonResolverService._fireAgentKInbound → ms-ai POST /internal/knox/agent-k/inboundapp/services/PersonResolverService.js, ms-ai/src/agent-k/
Outbound net-newAgentKOutboundJob (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_searchpeople/match reveal (1 credit, ≤15/scan) + PredictLeads signalsapp/jobs/AgentKOutboundJob.js, app/services/AgentKOutboundService.js, app/lib/apifySos.js
CRM reactivationAgentKReactivationJob (hourly; once/day per tenant), requires re_engage.revive_crm + GHLapp/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 feedai_agent_k.auto_push default false: released leads wait in the feed for a manual "Push to GHL" click; true restores automatic pushes. Deploy-day script scripts/grandfather-agent-k-auto-push.js grandfathered existing beta tenants to true.
  • 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 tagsNoxActionModel 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_level is a strict subdoc — cache keys must be schema'd or writes drop silently). kk_token / kk_why_now are fixed; linkedin_url / job_title names are tenant-configurable via ai_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>, plus nextlevel returned-to-site written 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), and touch_mode guide | verbatim (default guide, c13e3e403): filled copy is a brief the drafter rewrites per lead; verbatim sends it word for word. Existing Mode B tenants with filled boxes switched to guide on 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_CAP 20/tick). Filler guardrail: no copy from ms-ai = touch skipped, never a hollow message.
  • Daily send capai_agent_k.daily_send_cap (default 40, UI 10–150).
  • One auto-send gate (072feec9c) — NoxGhlActionService.autoSendDecision decides every send and records why: rules approved, agent_k_auto_send_warm, nox_autonomous, standing_rule; COLD_SOURCES = ['reactivation', 'outbound']. An Agent K message obeys ai_agent_k.sending_mode onlyknox.actions.autonomous_send no longer overrides it. Quiet hours (recipient tz) hold any auto-send. NoxActionModel gained send_rule and sent_by (default ''), which power the lead card's send receipt; the rule wording lives frontend-side only.
  • Sequence readGET /knox/actions/sequence?ghl_contact_id= (NoxActionUserController): the 3-touch ladder with next.due_at derived from AgentKSequenceJob.GAPS_MS and stopped: ghl_workflow | not_started | replied | complete; Mode A returns mode: 'push' with an empty ladder.
  • Feed buckets — a drafts bucket was added (ce6638c49) and removed again (e41c26e81): review is 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 a signal sub-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. handleInboundReply treats status: 'pushed' as outreach too (dec42a228), so Mode A replies to GHL-workflow-sent mail log lifecycle + led_to_reply; sent_at desc gives a KK-sent touch attribution over the bare push.
  • Stop-intent classifierNoxGhlActionService._classifyStopIntent → ms-ai POST /internal/knox/agent-k/classify-replyAgentKReplyService.classifyStop() (strict JSON {"stop": bool}, temp 0, maxTokens 50). Model = AGENT_K_STOP_MODEL env override, else the default classifier model — seed a cost-tracking price row before switching model. Fails open stop=false (a missed opt-out is recoverable, a wrong one is not); usage recorded under agent agent_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.resolve stitches the identity onto the session → writes nextlevel returned-to-site → funnel link_click/web_visit signals → widget auto-engage of the returning lead (with lead memory, cooldown knox.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_token mint 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-backfill stamps kk_token onto existing GHL contacts (requires the field to already exist; progress persisted at ai_agent_k.kk_backfill, socket kk-backfill-progress). The hourly KkTokenSyncJob and 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 — because PersonResolverService writes 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._finalize calls PersonResolver.recordContact() on every delivered path (never pending_push); new profiles are outbound_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 billingAgentKLinkedInRenewalJob (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-side agent-k-provider-pricing.ts. 60s in-process price cache.
  • Debitsai_credit_logs rows against remaining_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 on idempotency_key (partial-unique index) so retried scans re-charge nothing.
  • GateshasBalance() 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 $inc on provider_usage per (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-ai POST /api/cost-tracking/internal/provider-usage, idempotent on external_id agentk:<co>:<provider>:<day>; the emitted watermark makes retries safe.
  • ms-ai prices via src/cost-tracking/agent-k-provider-pricing.ts (USD/call: apollo_match 0.05, predictleads 0.02, unipile 0.01, apollo_search free; JSON env override AGENT_K_PROVIDER_USD, no deploy) into a usage_charge. Those credits flow into the existing token-usage sum and the hourly AITokensCreditJob deducts them. This path is the platform-cost ledger; the tenant's sell price lives in lib/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): a signal_type with ≥5 outcomes and ≥60% skips is demoted — unless it ever converted (reply/booking). Demote-only; never adds volume. Fed by GET /internal/knox/agent-k/signal-stats.
  • knox_agent_k_weights Nox tool (ms-ai) → POST /internal/knox/actions/agent-k-weightsai_agent_k.source_weight_overrides (multiplier clamped 0.25–3) consumed by AgentKSourceWeightService — rank-only, never volume.
  • AgentKWeeklyDigestJob (hourly tick; sends on ai_agent_k.digest.day in company tz, once per ISO week via digest_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 the auto_push=false feed).
  • 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-k page (views/agentk/Index.vue, Feed + Reports). The Feed is a three-pane inbox since 1e6159daAgentKInbox.vue (rail / lead / contact, resizable, AgentKQueue.vue deleted), AgentKRail.vue (tabs To review / Sent / Skipped / All, Channel + Why-now signal selects, search, All-dates range), AgentKLead.vue (enrichment, AgentKSequence.vue ladder, AgentKThread.vue live GHL thread, send receipt, safety-gate card, push chips), confirm dialogs before push/send/skip; tests in AgentKInbox.test.js / AgentKPanes.test.js. Report tiles drill into the lead list. Settings views/settings/ai/agents/AgentK/index.vue7 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.vue shows per-step prices before enabling. Admin: beta toggle in views/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, legacy kk_tokens index 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).