Backend Integration
AI — Backend Integration
Security reviewers: the trust-boundary model and the full tool capability matrix live in SECURITY.md — start there.
Audience: any client developer — web, mobile, or a third-party integration.
TL;DR: AI v2 is one chat. You send a message to a conversation; a supervisor decides who answers — the units agent (anything about your existing objects and records: read, create, update, delete, and structural edits) or the workspace builder (build a whole workspace). Both run inline on the same chat — there is no separate channel and no hand-off. A build pauses at a plan (or questions) gate the same way a write pauses at a writes gate; you resolve all of them through the one assistant:confirmation_required event. You integrate the chat over REST (request/response) or WebSocket (token streaming) — your choice, same backend. Onboarding is a separate, optional flow — you do not need it to use the chat.
1. The one thing to understand: one chat, a supervisor routes it
You do not call several different agents. You send one message; the supervisor classifies it and routes to one of just two specialists — split by scope of work, not read-vs-write:
| Specialist (routing key) | What it does | How you reach it |
|---|---|---|
units | Everything about your existing objects (units) and the records in them. Reads — "show my open deals", "who owns this contact"; record writes — "add a member", "set this deal to won", "delete this contact"; and structural edits — "add a budget field to deals", "make phone required". Every write is confirmed by the user first (§6). | Default. Just send a message. |
workspace_builder | Builds a whole workspace from a description (interview → editable plan → build objects/fields/relations/views/automations). Runs inline on this same chat — it pauses at a questions gate (interview) then a plan gate (review), both resolved via assistant:confirmation_required (§5). | The supervisor routes a build here. Same socket, no hand-off. |
The old
crm_records(read-only) /schema_manager(writes) split was collapsed into the singleunitsagent. If your client still pinscrm_recordsorschema_managerin Direct mode (§4), the backend maps both tounits— nothing to change urgently, but preferunitsgoing forward.Migration note (was: hand-off). Earlier the builder ran on a separate
/ai-v2/buildersocket reached via anassistant:handoffevent. That is removed: the builder now runs inline on/ai-v2/chat, andassistant:handoffis no longer emitted. If your client listens forassistant:handoff, drop that handler and instead handleassistant:confirmation_requiredwithtype: "plan"/type: "questions"(§5).
So for the common case, your integration is: create a conversation → send messages → render the answer.
2. Prerequisites
- Flag:
AI_V2_ENABLED=trueon the backend. With it off, every route below returns 404 (AiV2EnabledGuardruns first) — that's your feature-detection signal. - Auth: standard Bearer JWT (
Authorization: Bearer <access_token>), same token as the rest of the API. Mobile clients sendX-Client-Type: mobilefor mobile token TTLs. - Base path:
/api/v1/ai-v2. - Slugs, not IDs: conversations are addressed by
slug(a UUID). Never expose numeric ids.
Verify you're wired: GET /ai-v2/status → 200 when the flag is on and your token is valid.
3. REST transport (simplest — request/response)
All routes are @UseGuards(AiV2EnabledGuard, AdminAuthGuard).
| Method | Path | Purpose |
|---|---|---|
POST | /ai-v2/conversations | Create a conversation → returns its slug. |
GET | /ai-v2/conversations | List the caller's conversations (paginated). |
GET | /ai-v2/conversations/:slug | One conversation's metadata (no messages). |
GET | /ai-v2/conversations/:slug/messages | Full turn history — array of { index, role, content, block?, feedback? }. |
POST | /ai-v2/conversations/:slug/messages | Send a message, get the assistant's answer. |
POST | /ai-v2/conversations/:slug/confirm | Approve/decline a paused gate (REST parity for assistant:confirm). |
POST | /ai-v2/conversations/:slug/clear | Clear chat — wipe the transcript + ratings, keep the conversation. |
PATCH | /ai-v2/conversations/:slug/messages/feedback | Rate a message (thumbs up/down) or clear its rating. |
DELETE | /ai-v2/conversations/:slug | Delete a conversation (also drops its ratings). |
Message history (GET :slug/messages) returns turns with a stable 0-based index, plus the owner's feedback ("up" | "down" | null) on each. It replays the whole conversation, including the supervisor's clarify questions ("which did you mean?") and the partial text of a turn the user cancelled/refreshed mid-stream (marked with a trailing …) — so a reload never shows a user bubble with no reply. The supervisor also receives this recent history as routing context, so short replies ("1", "yes, build it") route by what the conversation is about. Rate a message with PATCH :slug/messages/feedback { "message_index": <index>, "feedback": "up" | "down" | null } — send null (or omit feedback) to clear it. Clear chat (POST :slug/clear) wipes the checkpoint transcript + ratings but keeps the conversation row (the next message starts a fresh run).
Send a message:
POST /api/v1/ai-v2/conversations/{slug}/messages
Authorization: Bearer <token>
{ "message": "show me my open deals", "agent": null, "object_slug": null }Both extras are optional. agent — omit (or null) to let the supervisor route; pin it ("units") when the UI already knows the target. object_slug — the slug of the object the user is viewing, to focus the AI on it. See §4 → "Pinning an agent from a page" and "Focusing the AI on the current object".
Response:
{
"message": "Message processed successfully",
"data": {
"conversation_slug": "…",
"answer": "You have 12 open deals…",
"tool_call_count": 1,
"usage": { "...": "credits/token usage for the turn" }
}
}The REST call blocks until the turn is done and returns the full answer. Use this when you don't need live tokens.
4. WebSocket transport (token streaming)
Connect the Socket.IO namespace /ai-v2/chat (auth: same Bearer token in auth.token / query / header). Use this when you want the answer to stream in.
You send:
assistant:send—{ conversationId?, message, agent?, object_slug?, answers? }(omitconversationIdto start a new conversation; the server assigns one).agentpins the specialist (Direct mode);object_slugfocuses the AI on the object the user is viewing;answerssupplies the user's replies to a pending builder questions gate (§5) — all optional, see below.assistant:confirm—{ conversationId, approvedCallIds?, plan? }— approve the pending gate. For a writes gate, echoapprovedCallIds(§6); for a plan gate, optionally send the user's editedplan(§5).assistant:reject—{ conversationId }— decline the pending gate (writes not written / plan re-proposed)assistant:cancel_generation— stop the in-flight turn
On connect (once, not per turn):
| Event | Meaning |
|---|---|
assistant:ready | { agent: "supervisor", suggestions: string[] } — connection ack, emitted once when the socket connects (NOT per turn, and it carries no conversationId). suggestions are personalized starter prompts for an empty chat (from the owner's onboarding profile; [] ⇒ show your own defaults). Do not treat this as "turn started" — a turn's first event is assistant:thinking. |
Per turn — you receive (in order):
| Event | Meaning |
|---|---|
assistant:thinking | { conversationId } — turn accepted; the model is working (show a spinner). This is the per-turn start signal. |
assistant:agent | { agent, agentName, routedBy } — which agent this turn was routed to (routedBy: "supervisor" = it classified; "direct" = you pinned it), fired before any token so you can switch UI mode (e.g. open the builder). agent is the stable key you switch mode on; agentName is the ready-to-render label (e.g. "Cortex Units") — show it verbatim, don't map the key yourself |
assistant:token | { delta } — append to the streaming bubble |
assistant:tool_start | { conversationId, toolName, toolArgs } — a tool is about to run (show chain-of-thought) |
assistant:tool_result | { conversationId, toolName, success, result } — a tool finished. success (boolean) = the tool ran without failing (a read that returned data is success:true; it is false only on a real failure or an explicit {success:false}). result is a string — a truncated (≤500 char) preview of the tool output, not a parsed object. No summary/duration_ms. |
| one terminal event ↓ | Every turn ends with exactly one of the three below (guaranteed — a run that stalls emits assistant:error at a server deadline, never silence). |
assistant:complete | { fullResponse, toolCalls, … } — the final transcript |
assistant:confirmation_required | { conversationId, message, type, ...body } — the turn paused at a gate the user must resolve before it continues. type is "writes" (Units writes — writes: […], §6), "plan" (builder plan review — plan: {…}, §5), or "questions" (builder interview — questions: […], §5). This is the terminal event of a gated turn — you will NOT get complete until you reply assistant:confirm / assistant:reject / (for questions) a follow-up assistant:send with answers. A client that only waits for complete will look stalled; handle this event. |
assistant:error | Something failed (also the guaranteed terminal for a stalled/timed-out turn) |
assistant:handoffis removed — the builder runs inline. A build turn ends withassistant:confirmation_required(type: "questions"or"plan"), not a handoff.
The REST and WS paths are the same engine — pick per screen. (These event names mirror v1's assistant:* so a v1 client migrates with minimal change.)
Switching UI mode on assistant:agent (web + mobile)
assistant:agent is your mode switch. It arrives before any token, so react to it to adapt the surface instantly — same handling on every client:
agent | What the UI should do |
|---|---|
units | Normal chat — render the answer bubble as usual. When the turn changes structure or data you'll get the confirmation card (§6); you may optionally badge "updating your workspace". |
workspace_builder | Switch to the builder surface inline — the turn will pause at a questions or plan gate (assistant:confirmation_required, §5) to render on that surface. There is no separate channel to open and no assistant:handoff to wait for. |
- Web: store
payload.agent(e.g.activeAgentin the AI panel store, set by the socket handler) and drive the surface off it. - Mobile: the same event on the same
/ai-v2/chatsocket — readpayload.agentand swap the surface (e.g. the builder view onworkspace_builder). No extra endpoint; it's one field on one event, identical to web. - REST-only clients (no socket): the same routing is on the
POST …/messagesresponse asrouted_to/routed_by(+kind), with a ready-to-render namerouted_to_name(e.g."Cortex Units"). Readrouted_toto switch mode androuted_to_nameto label it; a paused turn carrieskind: "confirmation"+ agate(§5/§6).
Pinning an agent from a page (Direct mode)
assistant:agent lets you react to where the backend routed. Direct mode is the reverse — the frontend tells the backend which agent to use, skipping the supervisor. Use it when the current screen already knows the target, so the user doesn't have to phrase the request in a way the classifier will route correctly.
Set agent on the message you send (WS assistant:send or the REST body):
// On the Units page, pin the units agent for every message:
{ "message": "add a budget field to deals", "agent": "units" }agentvalues:"units","workspace_builder". Omit it (or sendnull) to let the supervisor classify. (Legacy"crm_records"/"schema_manager"are still accepted and both map to"units".)unitsis the objects-module agent — it reads records AND edits objects/fields/relations. Pin it while the user is on a Units/object screen so any ask there goes straight to it.- Safe fallback: an unknown or misspelled key is ignored and the turn falls back to the supervisor — a bad
agentnever errors the turn. - It's per-message, not a session mode. Send
agenton each message while the page is active; drop it (or change it) when the user leaves. There is no "enter mode" call to make or unwind. - Same downstream events. A pinned turn still emits
assistant:agent, now withroutedBy: "direct"(vs"supervisor") — so the "Switching UI mode" handling above works unchanged. If you pin an off-surface agent (workspace_builder) you'll get theassistant:handoffcommit just like a supervisor route.
Nothing here is platform-specific server-side — web and mobile consume the identical event.
Focusing the AI on the current object (object_slug)
Pinning the agent answers "which specialist". Setting object_slug answers "about what" — it tells the AI which object the user is looking at, so "add a budget field" on the Deals page means Deals without the user typing "to Deals".
Set object_slug on the message you send (WS assistant:send or the REST body), alongside agent. It's the object's slug — the same value you already use to address the object page/API — not its display name. CRM object slugs are opaque numeric identifiers (e.g. 482910375829), so send that, not "deals":
// On an object page (say the object whose slug is 482910375829) —
// pin the Units agent AND focus it on this object:
{
"message": "add a budget field",
"agent": "units",
"object_slug": "482910375829",
}- It's a soft default, not a lock. The AI treats unqualified requests ("this object", "here", nothing named) as about the focused object, but obeys any object the user names explicitly — "actually add it to Companies" still works from that page.
- Send the slug, not the label. Passing a display name like
"deals"won't resolve — the object won't be found and the focus is silently ignored. Use the numeric slug the object is addressed by. - Which agents use it:
units(reads of, and edits to, that object).workspace_builderignores it — it builds whole workspaces. - Safe + sanitized. The slug is validated server-side (must be slug-shaped, ≤128 chars); anything else is dropped, never a 400. An unknown-but-valid slug is passed through and the AI ignores it in-band, so a stale page never breaks a turn.
- Per-message, like
agent. Send the current object's slug on each message while the page is open; drop or change it on navigation. Independent ofagent— send either, both, or neither. - Pairs with pinning: on an object's page you typically send both —
agent: "units"(which specialist) andobject_slug(which object).
5. Building a workspace (inline, on this same chat)
If the user asks to build a whole system ("build me a real-estate CRM"), the supervisor routes the turn to workspace_builder (assistant:agent with agent: "workspace_builder"). The builder runs inline on /ai-v2/chat — there is no separate channel and no assistant:handoff. It drives a two-gate flow, and each gate arrives as an assistant:confirmation_required you resolve on the same socket:
-
Interview —
type: "questions".// assistant:confirmation_required { "conversationId": "…", "message": "A few quick questions…", "type": "questions", "questions": [ { "id": "q1", "prompt": "What do you track?", "type": "multi", "options": [ { "value": "deals", "label": "Deals" } ] } ] }Render the questions as buttons. Answer by sending a normal
assistant:sendwith theanswersfield (NOTassistant:confirm):assistant:send { conversationId, message?, answers: [ { question_id: "q1", values: ["deals"], text?: "…" } ] }. The builder may ask another round (anotherquestionsgate) or move on to the plan. -
Plan review —
type: "plan".// assistant:confirmation_required { "conversationId": "…", "message": "Here's a plan…", "type": "plan", "plan": { "template_name": "…", "objects": [ … ], "relations": [ … ], "automations": [ … ] } }Render the plan as an editable preview. Then:
- Approve →
assistant:confirm { conversationId, plan? }— send the user's editedplanto build that, or omit it to build the pending plan as-is. The build then streamsassistant:tool_start/assistant:tool_resultper step (create_object,create_field,create_relation,create_view,seed_sample_data,create_automation,create_folder) and ends withassistant:complete. - Revise → send a normal
assistant:sendwith free-text change requests while the plan is pending → the builder re-proposes (anotherplangate). - Decline →
assistant:reject { conversationId }.
- Approve →
Answers/plans are untrusted → sanitized server-side (shape + caps); a malformed entry is dropped, never a 400. REST parity: the same gates arrive on the POST …/messages response as kind: "confirmation" + a gate: { type, … }; answer a questions gate by POSTing a new message with answers, and resolve a plan gate via POST …/confirm { approved, plan? }.
The plan and questions persist in history. When you reload a conversation with GET /ai-v2/conversations/:slug/messages, a builder turn that proposed a plan or asked questions comes back with a typed block on the turn — { role: "assistant", content: "…", block: { type: "workspace_plan", plan } } or { …, block: { type: "builder_questions", questions } } — so you re-render the plan card / question buttons exactly as they appeared live, not just the summary line. Turns without rich UI have no block. Render block by its type; you never map any tool or agent name.
The builder's internals (interview strategy, the plan schema, the deterministic build) are documented in the Workspace Builder spec.
6. Write confirmation: the user approves every change (human-in-the-loop)
The units agent never writes without the user's explicit approval — this is a platform law, not a per-agent option (see CONFIRMATION.md). Whenever it wants to create, update, or delete anything — a record, a field, an object, a relation, a view — the turn pauses and the server emits assistant:confirmation_required instead of assistant:complete. Nothing is written until the user approves.
The flow (all on the same /ai-v2/chat socket):
- The turn streams as usual (
assistant:agent→assistant:token…), then instead ofassistant:completeyou get:// assistant:confirmation_required { "conversationId": "…", "type": "writes", // discriminates this from a builder plan/questions gate (§5) "message": "I'll delete the deal r1 — confirm to proceed.", // the agent's lead-in, render as a message "writes": [ { "callId": "call_abc", // opaque id — echo it back to approve THIS write "action": "delete", // "create" | "update" | "delete" — pick your icon from this "label": "Delete record r1 in 482910375829", // READY TO RENDER — show verbatim "tool": "delete_record", // technical, for logging only — DO NOT branch on this "objectSlug": "482910375829", // optional, for a "view record" link "recordSlug": "r1", // optional "args": { … } // the raw call args, for debugging only } ] } - Render a confirmation card: the
message, then one row perwrites[i]showinglabel(and an icon chosen fromaction). Show Approve and Decline. - On Approve → send
assistant:confirm{ conversationId, approvedCallIds: writes.map(w => w.callId) }. The run resumes, performs the approved writes (you'll seeassistant:tool_start/tool_resultthenassistant:complete). - On Decline → send
assistant:reject{ conversationId }. Nothing is written; the agent acknowledges andassistant:completefollows.
Render label/action verbatim — never map tool names. The backend derives a ready-to-render label and a stable 3-value action for every write, including new tools added later. Your card shows label as-is and picks an icon from action (create/update/delete). A new confirmable tool needs zero frontend changes — do not build a per-tool label/icon table on the client.
Notes:
- Approve exactly what you were shown.
approvedCallIdsmust be thecallIds from the card. Omitting one leaves that write unapproved (the server refuses it, fail-closed); sending an id that wasn't pending is a harmless no-op. A simple card sends all of them. - A turn can pause more than once. After you approve a batch the agent may propose another — you'll get a second
assistant:confirmation_required. Handle it the same way. - No mass delete. The agent will not offer a large delete batch: over the per-turn cap it declines up front and tells the user to delete fewer. You never receive an over-cap delete to approve.
- Backend-enforced, not just UI. Approval is enforced server-side (the policy guard fail-closes every unapproved write) — the card is the UX, not the security boundary. A missing/lagging card can never cause an unconfirmed write.
- REST parity: the non-streaming
POST …/messagesresponse carries the same pause askind: "confirmation"+ agate: { type: "writes", writes: [...] }(the writes shape matches the WS event —callId,action,label, …). Resume withPOST /ai-v2/conversations/:slug/confirm{ approved: true, approved_call_ids: [...] }to approve, or{ approved: false }to decline.
6b. WhatsApp: the second channel (backend-owned — no client work)
The WhatsApp AI assistant runs on this same v2 stack — AiGraphService.run with
channel: 'whatsapp' — via a Redis-driven listener (v2/channels/whatsapp/),
not a socket client. Nothing here changes web/mobile integration; it is
documented so the channel model is understood:
- Channel profiles (
v2/channels/channel-profile.ts): an exhaustive per-channel table.whatsapppins the units agent (no builder on a text channel) and appends plain-text formatting rules to the system prompt on the thread's first turn. Adding a future channel = one union member + one profile row; the compiler forces the rest. - Write confirmation over text: a
confirmationturn renders itswrites[].labellist as a numbered WhatsApp message ("Reply yes / no"); the gate'scallIds wait in Redis and a bare "yes/نعم" resumes viaresumeConfirmationRunwith thoseapprovedCallIds— the same fail-closed guard path as §6. Any other reply abandons the gate and runs as a fresh turn. - Sessions: one conversation slug per phone with a 30-minute sliding window — after silence the next message starts a clean conversation (industry-standard inactivity timeout for action-taking bots).
- Identity: phone →
admin_whatsapp(verified links only, globally unique number) → admin, then the turn runs insideTenantScope.runInScopepinned to the admin's workspace. The credit ledger recordschannel: 'whatsapp'. - Flag: rides on
AI_V2_ENABLED(plus the channel's ownWHATSAPP_AI_ENABLED). There is no v1 fallback — v2 off means the bot is off.
6. What you do NOT need for the chat
- Onboarding — a separate WebSocket flow (
/ai-v2/onboarding, flagAI_V2_ONBOARDING_ENABLED) for first-run setup. The chat does not require it — there is no onboarding-completed gate on the chat routes. Skip it entirely and call the chat directly. - The builder — only if you want the "build a workspace" experience (§5).
7. Flags at a glance
| Flag | Gates | Default |
|---|---|---|
AI_V2_ENABLED | The chat — REST /ai-v2/* and WS /ai-v2/chat — including the inline workspace builder | off |
AI_V2_ONBOARDING_ENABLED | Onboarding (/ai-v2/onboarding) | off |
For chat integration — including building a workspace — you only need AI_V2_ENABLED; the inline builder rides on it. Onboarding is an independent opt-in. (The old AI_V2_BUILDER_ENABLED flag and its standalone /ai-v2/builder channel were retired — the builder is inline now.)
8. Where to go deeper
- AI Architecture guide — the supervisor → specialists design, layers, checkpoints.
- Units Agent spec — per-agent detail (tools, permissions, limits, the write-confirmation gate).
- Onboarding Agent / Workspace Builder — the two separate flows, if you support them.