Changelog
What's new.
Every improvement to the platform, as it ships.
· SecurityService
Bulk-reassign endpoint, gated on REASSIGN_LEADS_MANUALLY
POST **************** moves a batch of leads onto one team member and emails that member once for the whole batch. Authorization uses REASSIGN_LEADS_MANUALLY (149) — the right named for exactly this. That is stricter than the single-lead path on PUT /leads/{id}, which checks ASSIGN_LEADS; reassigning a hundred leads is a different blast radius from reassigning one, and the two rights exist so they can be granted apart. The unassigned-visibility tier is enforced here too. Assignment IS a visibility grant: once a lead is assigned to the caller it reads as "assigned to me", which lifts the unassigned-read 403 and skips the contact-info mask. Without this check a caller holding 149 but not VIEW_UNASSIGNED_LEADS could self-assign leads the grid hides from them and launder them into fully readable ones. Skipped leads report NOT_FOUND — matching what their own grid shows — so the response can't enumerate hidden or foreign leads. The batch is deliberately not all-or-nothing: each assignLead runs in its own transaction and a bad id becomes a skip, because discarding nine good reassignments over the tenth is worse than reporting which one didn't take. Capped at 250 per call to bound both the row locks and the realtime fan-out. The notifier resolves its data in a short transaction through a separate bean and sends afterwards, so an outbound call with a 30s read timeout never pins a CRDB connection. Crossing the bean boundary is also what makes @Transactional apply at all — self-invocation would silently run with no transaction and the LAZY user association would throw.
· EmailService
Sample variables for the LEADS_ASSIGNED template
Without a case here the admin template editor's Preview renders the new template with every placeholder blank, which reads as a broken template rather than an unfilled one. notesBlock is pre-rendered HTML because the renderer has no conditionals — the sender collapses it to "" when there is no note.
· kamo-shared-library
LEADS_ASSIGNED email template; harden assignLead for bulk use
assignLead had zero callers and had never run in production. Before wiring the bulk reassignment path to it, give it the guarantees that path needs: - take orgId and verify BOTH the lead and the new assignee belong to it, so a reassignment can't reach across tenants (reported as "not found" rather than "forbidden" so the error can't be used to probe foreign lead ids) - lock the row with findByUidForUpdate: a manual reassignment and a concurrent pool-accept target the same lead and would otherwise interleave into a lost update - clear the pool flags, mirroring CreditLeadAssignmentService. A lead marked for auto-assignment otherwise stays in the pool after being claimed by hand and gets taken out from under its new owner - publish chat routing, as updateLead already does, so the borrower's open live chat follows the lead instead of staying with the previous officer Also add the LEADS_ASSIGNED canonical template. The key must be in CANONICAL_KEYS or it can never be seeded and can never self-heal, and every send returns a permanent 404.
· kamo-shared-library
Add MANAGE_ORG_BRANDING (174)
Org branding endpoints (logos, colors, backgrounds, theme provisioning) were owner-or-god only, leaving no way to delegate branding without handing over ownership of the org. Adds the delegable right; owner checks stay as an implicit grant so existing owners keep working without being assigned it.
· kamo-internal
Author eligibility matrices from the catalog panel
Without a matrix covering a scenario's occupancy and purpose, pricing returns NO_COVERAGE — so before this, only a purchase of a primary residence could be priced. The form authors a matrix and its cells for a chosen limit kind, occupancy and purpose, with add/remove cell rows, alongside the lock-policy form in the same panel. Leaving the product unset authors one matrix per ACTIVE product of the investor, because the matrix's product scope is NOT NULL and cannot express "all products" as a single row.
· SecurityService
Relay eligibility-matrix authoring; rebuild picks up the SUSPENDED resume
Relays POST **************** through the existing org-stamping write helper, so a steward authors matrices only for their own tenant. This push also rebuilds against the shared-lib change that lets a SUSPENDED loan resume. kamo-shared-library has no CI of its own — each service clones and builds it from source — so that fix reaches this service only when this service rebuilds.
· kamo-internal
Rebuild the agent studio as a full-width workspace
Removing the width cap was not the fix; the layout itself needed to be designed for the space rather than tolerated in it. The studio is now three zones side by side: - **Outline rail** — the sections, with the current one highlighted as you scroll (IntersectionObserver, top-weighted so a section becomes current when its header reaches where attention actually is). Sections written by hand are marked, so a hand-written part is findable later without opening every one. - **Editor** — the sections themselves, tiling into as many columns as the viewport holds instead of one narrow ribbon. - **Live conversation** — a real column on a wide display, not a strip that collapses the content when opened. This is the edit-and-watch loop the studio exists for: change an instruction, see the answer change, without navigating. One TestDrivePanel instance, repositioned rather than duplicated — mounting it twice would start two throwaway sessions. Below xl it drops beneath the editor as a full-width row rather than squeezing a third column onto a display with no room for one, and the rail hides entirely on small screens.
· kamo-internal
Author lock policies — without one, no rate can be locked at all
RateCommitmentService refuses a lock whenever the priced result carries no lockPolicyVersionId ("lock-policy version is required"), and a result only carries one if the investor has a published lock-policy version. No UI could publish one, so rate locking was unreachable for every tenant — while the endpoint itself was already built, org-gated and superseding correctly. Only the client method and this form were missing. The form takes the offered lock periods as an ordered list because the FIRST becomes the default, so it deliberately does not sort them; entries that are not a positive whole number of days are dropped rather than sent as a bad lock term.
· kamo-internal
Show how each agent is doing
Adds a performance section to the top of the agent studio: conversations, how often a person took over and what triggered it, outreach outcomes, and cost. Written so no figure can mislead: - Zero renders as the word 'None', never 0. - A share renders only when there is a denominator; otherwise 'No conversations yet to compare against'. There are no zero-denominator percentages. - When nothing in the period carried a price, NO cost figure is shown at all - just an explanation that pricing was set up after the traffic happened. Partial coverage shows the amount plus how many replies are missing from it. - Suppressed outreach is presented as the feature it is: 'These are kept on purpose - they're your record of why someone wasn't contacted.' - Unknown enum codes degrade to readable text rather than a raw key path. Per-version message and handoff columns were removed: the API does not aggregate them per version, so those cells would have rendered a fabricated 0 as though it had been measured. Conversations alone answer what the table is for - which saved copy handled which traffic. Folded into the existing scrolling studio document rather than a new tab, and kept off the agent list, where a bare 0 against every agent would be the exact misread being avoided.
· AIService
Per-agent scorecard
What each agent actually did: conversations, how often a person had to take over and why, outreach outcomes including the ones held back, and what it cost. Every number is one the data can actually support. Three things are deliberately NOT here: - **No token totals.** They are real on the sync path and fabricated on the streaming one (content.length()/4), so any total is part invented. Cost answers the same question honestly, because it is null rather than guessed where the price is unknown. - **No resolution or deflection rate.** Nothing observes whether a conversation ended well, and 'no handoff occurred' is not evidence of success. - **No per-version message or handoff counts.** Those are not aggregated per version, and emitting 0 for them would be a fabrication rendered as a measurement - the precise failure this screen exists to avoid. Cost carries an explicit tracked flag and a null amount rather than 0 when nothing in the window was priced, so an org whose traffic predates pricing sees 'not recorded' instead of a confident $0.00. pricedCalls is the authority rather than the sum, so a genuine zero from priced free-tier calls still reads as tracked. Transcript prose is gated on VIEW_SALES_AGENT_TRANSCRIPTS separately from the counts - the model's summary of a specific visitor is conversation content, not a metric. Agents with no traffic return a structurally complete empty scorecard: an agent missing from the list reads as a bug, an agent present with zeros reads as the truth. parseHandoffArguments is now shared with the chat path rather than reimplemented, so the coercion of invented reasons to OTHER cannot drift between the two. Suite is 150 tests, green.
· kamo-shared-library
Grouped aggregate queries for the agent scorecard
Twelve @Query aggregates across sessions, messages, usage records and outbound touches, so a scorecard is a handful of grouped queries rather than loading rows into memory. Two properties are enforced in the predicates rather than left to callers: - **isTest sessions are excluded in the query itself.** On a young agent the author's own test drives would otherwise be most of the data, and a caller who forgets to filter produces numbers that look real and are not. - **count(costEstimate) counts only NON-NULL costs.** That is what lets the scorecard distinguish 'nothing was priced' from 'it cost zero' - collapsing those would put a confident $0.00 in front of an org whose traffic simply predates pricing. Outbound touches are grouped by decision rather than filtered to sends. The refusals are the more interesting half: an agent whose touches are nearly all SUPPRESSED_QUIET_HOURS is misconfigured, and that is invisible if decoders are discarded.
· kamo-internal
Edit what each model costs
Adds per-model price editing to the provider setup screen. Prices are seeded from each vendor's published list price where we know it, and blank where we do not - a blank means cost simply is not tracked for that model, which is better than a number that is confidently wrong. Copy tells people to check the seeded figures against their own bill rather than presenting them as authoritative. Fixes a display bug caught in review: the trailing-zero trim would have rendered a price of 10 as 1. It now strips zeros only after the decimal point.
· AIService
Model pricing catalog, write path and backfill
Cost accounting could never work: AiModel has price columns, AiModelController reads them and ChatOrchestrationService reads them to compute costEstimate - but NOTHING wrote them anywhere. Models are only created by provider discovery, and no provider API returns pricing. 140 models, 0 priced, no path by which that could change. The columns were structurally unreachable. Three parts: - ModelPricingCatalog seeds vendor LIST prices at discovery, matched on a normalized identifier with a hyphen-boundary prefix rule so gpt-4 can never claim gpt-4o. Non-chat models (TTS, embeddings, realtime) are rejected by reusing the existing modality check - they bill off a different sheet. - PATCH endpoint + UI so an org can correct or clear a price. Org-scoped exactly like the existing DELETE. - Backfill fills only NULL columns, per column independently, and never overwrites what an org has set - otherwise making prices editable would be a lie. Idempotent, and wrapped so cost accounting can never block boot. **A model we are not confident about gets NO price rather than a guessed one.** GPT-5, o3, most Mistral, DeepSeek, xAI, Cohere and Meta are deliberately blank; OpenRouter and HuggingFace never match by design, since they bill their own rates and inheriting upstream pricing would be a guess. A wrong price produces an invoice-shaped figure that is confidently wrong - the same reason cost is still not computed on the streaming path, where token counts are fabricated. Conversion to the per-1k column uses RoundingMode.UNNECESSARY on purpose: a price needing more than 6 decimals throws at class-init rather than silently storing a reason and is left blank. Suite is 145 tests, green.
· kamo-internal
Show the AI handoff context to the agent taking over
Renders an AI_HANDOFF system event as a card with the reason, the AI's summary, and a collapsible transcript of the conversation so far - so the borrower does not have to re-explain themselves to the person who picks up. The three degraded states are explicit and visible rather than silent: transcript unavailable, transcript truncated, and payload stripped in transit. An agent must never be left believing they have the full context when they do not. Visitor and agent share one STOMP topic, so the live frame arrives redacted (the AI's summary is its private assessment of the visitor and cannot go to their browser). Rather than telling the agent to reload, the card re-reads the full row once from its own authenticated endpoint. The re-read is validated before it is adopted - a response that came back still-stripped is rejected rather than used, so the warning can only ever clear when context was genuinely gained. Any failure falls back to the reload message.
· APIService
Proxy the public AI->human handoff route
Adds POST **************** proxied to MediaService alongside its createSupportSession sibling. Without this the handoff feature was dead on arrival: MediaService's endpoint hard-requires X-Internal-Auth, X-Public-Chat-Org-Id and X-Public-Chat-Key-Hash, which only this gateway injects, so the widget's POST 404'd here and never reached it. Carries the same conditional *** guard as createSupportSession. Handoff runs the full session-creation path, so proxying it bare would have left a ticket-creating route that bypasses the proof-of-work its sibling enforces.
· MediaService
Carry the AI conversation into the human thread on handoff
Creates or reuses the support session, copies the AI transcript in as one system event, routes it, and tells the visitor honestly what happens next. Confidentiality is split by audience, which the first pass got backwards. The AI's summary and reasonDetail are its private assessment OF the visitor, and the full payload was reaching the visitor's own browser through two REST doors and the STOMP frame while the agent saw nothing. Redaction now applies at every visitor-facing path; the persisted row keeps the full payload for the agent. chat.session.{guid} is a single topic both audiences subscribe to, so the live frame is cut to what the visitor may see and the agent UI re-reads the full row from its own authenticated endpoint. Idempotency is keyed on aiSessionGuid, org-scoped, and checked BEFORE creating anything. Keying it on the session and checking after creation - the obvious shape - is useless precisely when it matters: a retry that lost its session token always passes the check and produces a duplicate ticket and a second agent page. transcriptAttached now reports what actually happened. It previously came back true even when the write had silently failed, which is the worst possible combination: the visitor is told their context came across, the agent's thread is empty, and neither party knows. Copy-on-handoff, not migration: AI turns stay where analytics reads them.
· AIService
Agent-initiated handoff to a human, with context
The AI could not previously ask for a person: escalation rules reached the model via the compiled prompt, but there was no machine-readable signal back, so the model could only SAY it would fetch someone and nothing happened. - Synthetic kamo_request_human tool, described from the agent's own enabled escalation rule titles so the model sees identical trigger conditions in the prompt and the tool. Intercepted before MCP dispatch - it is a signal, not a real tool call - and the TOOL row it persists is what makes the reason durable and analytics-ready with zero new columns. - Attached ONLY when the channel actually has live support enabled. Handing an AI-only org a tool that promises a human who can never arrive is worse than having no tool: the widget has no Support tab, so the signal is dropped and the visitor is left waiting for someone who was never coming. - New internal endpoint returns the handoff reason, the AI's summary for whoever takes over, and the USER/ASSISTANT transcript. TOOL and SYSTEM rows are excluded - the human needs the conversation, not the machinery. - reasonDetail and summary are deliberately withheld from the visitor's browser: they are the model's private assessment OF the visitor. Only {requested, reason} crosses to the client; MediaService fetches the rest server-side. - AI turns are never moved or deleted. AiChatMessage stays the analytics system of record; the human side gets a copy. Also fixes a latent bug found on the way: the tools block ignored the per-model supportsToolUse flag, so a model explicitly flagged as not supporting tools was still sent them. 31 of 140 models are flagged false; ai_mcp_server_configs is empty cluster-wide, so today's blast radius is zero. Suite is 124 tests, green.
· MediaService
Accept agent-authored outreach into an open borrower thread
The counterpart AIService's outreach dispatcher calls. Without it the whole outbound path terminated in a recorded 404 - the gate authorized a touch, the message was composed, and there was nowhere to put it. Ownership stays split deliberately: AIService decides WHETHER a touch may go out and writes the text, this service places it. Writing these rows from AIService would fork ownership of the transcript to the service that owns neither the chat tables nor the socket. Two things it will not do. It never creates a conversation - outreach may resume a thread the visitor already opened, never conjure a chat window at someone who did not. And it never crosses orgs: a lead uid resolving to another org's ticket would put one org's agent inside another org's conversation, so the org is checked against the ticket rather than trusted from the caller. The message is attributed to the session member, exactly as a visitor message is - NOT to the assigned human officer. Rendering an AI-composed message as though a named, licensed person wrote it is what an AI-disclosure rule exists to prevent. ensureSubscription precedes the NATS publish because the consumer uses DeliverPolicy.New: publishing first would persist the message but drop it from every connected client. Returns both id and messageId so the outreach audit log references the message rather than the thread. 404 when no open thread exists, so the caller records a clean failure instead of assuming delivery.
· AIService
Outbound triggers, scheduler and web-chat dispatch
Makes the outreach spine actually fire. The gate, contact state, consent and audit already existed; nothing caused a touch to be evaluated. - NATS enabled for AIService via its own k8s/configmap.yaml (each Java service carries its own k8s dir; KlusterServices holds only cluster infra). JetStream is deliberately left OFF: NatsConfig asserts its own replication-factor against centrally-provisioned streams at startup, and AIService only subscribes to core NATS and publishes nothing, so enabling it would gain nothing and risk a second service asserting replica counts. - AgentOutboundConsumer subscribes security.leads.rt.> and consumes ONLY LEAD_CREATED/LEAD_UPDATED. The publisher also emits lock and stats events - reaching out to a person because a colleague took an edit lock on their record is not defensible. Ids arrive as strings from the publisher, so they are parsed as text rather than asLong(). - AgentOutboundScheduler evaluates schedule triggers under four bounds: org set derived from agents that actually have outreach on (capped), a thin sliding time slice so cost tracks new-lead rate rather than total leads, a per-tick row cap, and a re-evaluation damper so a lead crossing during quiet hours is retried once rather than every tick for a day. - Composition seeds textComplete with the PUBLISHED version's compiled prompt, never the draft, plus the trigger intent. Composition failure records FAILED; there is no canned fallback message. - WEB_CHAT dispatch continues an existing thread only. Outreach may resume a conversation the visitor opened; it may not conjure a chat window at someone who never opened one. - SMS/EMAIL/SOCIAL_DM route to a recorded FAILED naming the missing compliance plumbing, so an org pointing a trigger at SMS sees why in the outreach log immediately instead of nothing happening. 23 new tests; suite is 68 and green.
· kamo-internal
Required-text editor; dedupe AI general settings; fix model toggle
Adds an editor for org-wide required opening/closing text - the compiler and columns existed but nothing could set them, so the capability was dead. Copy states plainly that agents cannot remove it and that saving applies it to every agent immediately, and the toast reports how many agents were republished rather than a bare 'Saved', because live agents just changed what they say. Two pre-existing bugs found along the way: - The 'Auto-select best available model' switch has never worked. The frontend read data.autoSelectModel and sent autoSelectModel, but the backend returns and expects autoModelEnabled - so the toggle always rendered true and its value was discarded on every save. Fixed frontend-side; no server change needed. - AiGeneralSettings.tsx was dead code, never imported, while a verbatim inline duplicate lived at the bottom of SecurityModelsTab.tsx. Rather than implement required text twice, the duplicate is deleted and SecurityModelsTab imports the shared component. Also replaces the sales-agents help tip, which still told users the feature was coming soon.
· AIService
Persist org-wide required text and apply it immediately
AiOrgSettings gained forcedPreamble/forcedFooter and SalesAgentCompiler already folds them into every agent, but the settings controller neither read nor wrote them - so the capability was dead: a save appeared to succeed and came back blank on reload. GET now returns both fields (including in the no-settings defaults) and PUT persists them, normalizing blank to null so clearing a field actually clears it rather than leaving an empty string to be folded in as a stray separator. Required text now takes effect IMMEDIATELY, by recompiling and republishing every published agent in the org. Letting it apply on each agent's next publish was cheaper and is a trap: an admin who adds 'always say you are an automated assistant' will believe it is live, and an agent nobody happens to republish would keep talking without it indefinitely. A required line that silently does not apply is worse than not offering one. Each affected agent gets one new saved copy labelled with why, and the response reports how many were republished so the UI can say what actually happened instead of just 'Saved'.
· kamo-internal
Full-page AI Sales Agent studio, replacing the modals
The agent editor was a ~800px MUI Dialog whose entire surface was a 10-row textarea, on the one AI tab that used a dialog at all. It is now a dedicated route, and the editing model behind it is structured rather than one blob. - /settings/ai/sales-agents/new — a 720px column, not the studio. Template cards (the card IS the industry, so no industry dropdown), then two fields: name and one sentence about the business. A working agent in under a minute. - /settings/ai/sales-agents/[id] — full width, ONE scrolling document with sticky section headers, content capped at 880px so single-line inputs are not stretched across a 4K display. No 240px nav rail: nine sections is a scroll-spy job, not a nav job. - Try-it-out is a header toggle rather than a permanently reserved column. An empty chat pane beside a brand-new agent is wasted space that reads as the user's failure to fill it. - Readiness is an ordered, dismissible list of what would help — never a fraction. A fraction tells someone they are failing; a list tells them what to do next. - Locked rules render locked and unremovable, and taking a section over by hand warns when it contains one (the compiler now re-appends them regardless). - Header states plainly that edits only affect NEW conversations, because the prompt is snapshotted at session creation and users would otherwise conclude their change did not work. - outboundEnabled is only sent when actually changed: the server 403s the whole PUT if that field is present without MANAGE_SALES_AGENT_OUTREACH, so including it in every autosave would break the studio for anyone lacking that right. Also removes the duplicate raw "AI system prompt" box from the chat channel editor. It was a second, weaker place to author the same thing, with no versioning, no locked rules and no history — whichever editor you happened to open decided what customers heard. Existing prompts still resolve; channels using one now get a notice pointing at agents. Copy carries no AI jargon by design, and the hardcoded English industry array is replaced with translated labels.
· AIService
Sales-agent compiler, versioning, outreach gate; fix tool replay
Structured agent config compiles to the system prompt, freezes per published version, and gains the gate an agent must pass before initiating contact. Compiler - AgentConfig + SalesAgentCompiler: pure, deterministic compile of structured config into a sectioned prompt. Empty sections are omitted so a three-rule agent does not compile to a document that is mostly headings. - Section-level override ("write this part yourself") rather than a document-level raw-edit switch: an author gets full expressive power on one section while every other section keeps compiling, keeps its locked rules and keeps versioning. - That same mechanism makes migration byte-identical - a pre-config agent's prose is stored as a whole-document override and compiles back unchanged, so migration day is a no-op for agents already talking to customers. Proven by test. - PhraseBank is deliberately two dimensions, with the good prose on the TEMPLATE. A global bank keyed on every enum combination needs ~90 hand-written paragraphs, degenerates into stacked clauses, and would make every org open with the same sentence - which destroys the one thing an org is buying. - Six templates (front-desk and inbound-lead first: they are horizontal and cover any org whose vertical is not listed). Each ships locked compliance rules a model can never remove, verified by test. Lifecycle + authorization - Publish freezes {config, compiledPrompt, hash, model, provider}; rollback repoints and restores the draft so the studio can never show one thing while the agent serves another. - Controller is rights-gated for the first time. Previously ANY authenticated member of any org could rewrite what that org says to its customers. Falls back to MANAGE_AI_SETTINGS so no existing admin is locked out on deploy. - Public chat prefers the published version's frozen prompt over live trainingContext, and stamps salesAgentId/versionId on the session - the agent was already resolved there and was being discarded. - AI-assisted drafting: one sentence in, reviewable config out. Locked rules are carried over in code, never shown to the model, so a compliance line can never be deleted by instruction-following. Outreach gate (all 14 rules tested) - Opt-out beats every other check AND is reported as the reason, because the reason recorded against a refusal is itself the evidence. - SMS with no consent record is refused: under TCPA the burden of proof is on the sender, so absence of a record is absence of consent. - Quiet hours are judged where the CONTACT is, not where the business is. - Failed sends do not spend the touch budget; suppressed sends do not either, or one block would cascade into blocking everything after it. - The gate fails CLOSED - an outreach gate that errors open sends messages nobody authorized, the one failure here that cannot be undone. Fixes found along the way - Tool-call history replay was broken: the TOOL row persisted no content and the assistant tool_use turn was never persisted, so turn 2 sent a null-content blocks. Enabling tools would have failed every multi-turn conversation. - textComplete had no timeout (blocking .get(), pinning a thread on a slow provider) and recorded no usage, making every helper call invisible to billing. - System partition in context fitting was uncapped, so a verbose agent plus a RAG block could crowd out all history. - costEstimate is now populated on the sync path only, from real provider tokens. Streaming stays null: it fabricates tokens as content.length()/4 and multiplying that by a real price yields an invoice-shaped number that is wrong.
· kamo-shared-library
Structured sales-agent config, version spine and outreach state
Replaces the single training-context blob with a structured, versionable agent model, and adds the per-contact spine an agent needs before it may initiate contact rather than only reply. - SalesAgent gains configJson (jsonb-as-String, matching WebChatIntegration .designConfig), hasConfig, publishedVersionId, audience and outboundEnabled. trainingContext is retained as the COMPILED OUTPUT so every existing consumer keeps working untouched. hasConfig is explicit rather than inferred from trainingContext being blank, because once it is compiled output it is never blank and any blankness test would be unreachable. - SalesAgentVersion freezes {configJson, compiledPrompt, compiledHash, modelId, providerId} per publish. The model is pinned alongside the prompt: freezing instructions while the model auto-routes would make the artifact a half-truth. - AiChatSession/AiUsageRecord gain salesAgentId + salesAgentVersionId. Previously "which agent handled this?" could only be answered by walking the mutable, nullable, non-FK WebChatIntegration.salesAgentId hop, so reassigning a channel silently re-attributed all history. - AiOrgSettings gains forcedPreamble/forcedFooter as NEW nullable columns rather than reusing defaultSystemPrompt, so concatenation is a no-op until an admin fills one in. Reusing the existing field would have changed behaviour for every org that already had one populated, on deploy day. - AgentContactState + AgentOutboundTouch: per-contact cross-channel suppression, explicit per-channel opt-out columns and timestamped consent. Opt-out is stored as real columns rather than JSON because "prove this number was suppressed on that date" must not depend on JSON path extraction. The touch log records REFUSALS as well as sends - a log of sends proves what you did, a log of decisions proves what you would not do. - Rights 168-173 split **************** off MANAGE_AI_SETTINGS(53), which previously gated none of this.
· kamo-internal
AI Sales Agents tab + per-channel agent assignment
Builds out /settings/ai?tab=sales-agents (was a stub): orgs create agents for their sales industry, either from a built-in template or by writing the training context themselves, then edit/enable/delete them. Chat channels (/settings/features/chat) can now each pick which agent drives that channel's AI, independently — the agent overrides the raw system prompt.
· SecurityService
Enrich borrower chat routing + capture subject-property state
chat-routing now returns the assigned officer's NMLS, title, photo, phone, email and whether they hold a valid license for the subject-property state, plus the state itself and whether we still need to ask for it. Reuses the SAFE-Act card's resolved data so the chat can never show a fabricated identity. Adds POST /portal/subject-state: when the lead has no property state yet, the borrower can set it from chat; it flows through patchEstimate (and its assignment pool re-entry) like any other claimed fact, which unlocks the license line.
· MediaService
Assign a sales agent per channel + report LO availability
- WebChatIntegration CRUD threads salesAgentId (null clears the assignment) - lo-presence now returns the member's availabilityStatus alongside online, so the widget can show a real status instead of a bare dot. Cross-org probes still return only {online:false}.
· AIService
Sales-agent CRUD, mortgage template, and agent-driven chat prompt
- AiSalesAgentController (/api/ai/sales-agents): org-scoped CRUD + GET /templates - SalesAgentTemplateRegistry: built-in starter templates, beginning with a mortgage-borrower assistant (compliance-aware: no binding rate quotes, no approval promises, defers to the licensed loan officer, asks for subject state) - PublicAiChatService: the assigned SalesAgent's trainingContext becomes the top tier of the prompt waterfall (agent -> integration prompt -> org -> generic)
· kamo-shared-library
SalesAgent entity + per-channel agent assignment
Org-configurable AI sales agents: a named agent for a sales industry, programmed with a training context the org authors (from a template or by hand), assignable to channels to drive that channel's AI. Kamo serves all industries — mortgage is just one template. - SalesAgent entity (dao/beans/ai) + SalesAgentRepository + SalesAgentDTO - WebChatIntegration.salesAgentId (nullable soft link) + DTO field
· SecurityService
Auto-provision platform-managed MLOS chat + serve site key by host
- ManagedChatIntegrationService: lazily mint the org's platform-managed SPECIFIC_AGENT WebChatIntegration on first access. KamoMLOS is first-party: org resolved by host, the public site key is platform-owned and never managed by org admins. - PortalChatConfigController GET **************** resolve org by host, get-or-create the managed integration, return the public site key for <kamo-chat>. (The routing-token mint endpoint + chatRoutingForGuid + assignLoanOfficer chat-routing event landed in 67974fd via an interleaved push; this adds the config/provisioning side and requires shared-lib 6b161b5.)

Your universe is waiting.
Start free in minutes. Add products whenever you are ready — no migration, no new logins.
Free forever — no credit card required