Corteksa

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 doesHow you reach it
unitsEverything 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_builderBuilds 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 single units agent. If your client still pins crm_records or schema_manager in Direct mode (§4), the backend maps both to units — nothing to change urgently, but prefer units going forward.

Migration note (was: hand-off). Earlier the builder ran on a separate /ai-v2/builder socket reached via an assistant:handoff event. That is removed: the builder now runs inline on /ai-v2/chat, and assistant:handoff is no longer emitted. If your client listens for assistant:handoff, drop that handler and instead handle assistant:confirmation_required with type: "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=true on the backend. With it off, every route below returns 404 (AiV2EnabledGuard runs 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 send X-Client-Type: mobile for 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/status200 when the flag is on and your token is valid.


3. REST transport (simplest — request/response)

All routes are @UseGuards(AiV2EnabledGuard, AdminAuthGuard).

MethodPathPurpose
POST/ai-v2/conversationsCreate a conversation → returns its slug.
GET/ai-v2/conversationsList the caller's conversations (paginated).
GET/ai-v2/conversations/:slugOne conversation's metadata (no messages).
GET/ai-v2/conversations/:slug/messagesFull turn history — array of { index, role, content, block?, feedback? }.
POST/ai-v2/conversations/:slug/messagesSend a message, get the assistant's answer.
POST/ai-v2/conversations/:slug/confirmApprove/decline a paused gate (REST parity for assistant:confirm).
POST/ai-v2/conversations/:slug/clearClear chat — wipe the transcript + ratings, keep the conversation.
PATCH/ai-v2/conversations/:slug/messages/feedbackRate a message (thumbs up/down) or clear its rating.
DELETE/ai-v2/conversations/:slugDelete 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? } (omit conversationId to start a new conversation; the server assigns one). agent pins the specialist (Direct mode); object_slug focuses the AI on the object the user is viewing; answers supplies 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, echo approvedCallIds (§6); for a plan gate, optionally send the user's edited plan (§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):

EventMeaning
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):

EventMeaning
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 eventEvery 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:errorSomething failed (also the guaranteed terminal for a stalled/timed-out turn)

assistant:handoff is removed — the builder runs inline. A build turn ends with assistant: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:

agentWhat the UI should do
unitsNormal 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_builderSwitch 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. activeAgent in the AI panel store, set by the socket handler) and drive the surface off it.
  • Mobile: the same event on the same /ai-v2/chat socket — read payload.agent and swap the surface (e.g. the builder view on workspace_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 …/messages response as routed_to / routed_by (+ kind), with a ready-to-render name routed_to_name (e.g. "Cortex Units"). Read routed_to to switch mode and routed_to_name to label it; a paused turn carries kind: "confirmation" + a gate (§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" }
  • agent values: "units", "workspace_builder". Omit it (or send null) to let the supervisor classify. (Legacy "crm_records" / "schema_manager" are still accepted and both map to "units".)
  • units is 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 agent never errors the turn.
  • It's per-message, not a session mode. Send agent on 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 with routedBy: "direct" (vs "supervisor") — so the "Switching UI mode" handling above works unchanged. If you pin an off-surface agent (workspace_builder) you'll get the assistant:handoff commit 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_builder ignores 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 of agent — send either, both, or neither.
  • Pairs with pinning: on an object's page you typically send bothagent: "units" (which specialist) and object_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:

  1. 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:send with the answers field (NOT assistant:confirm): assistant:send { conversationId, message?, answers: [ { question_id: "q1", values: ["deals"], text?: "…" } ] }. The builder may ask another round (another questions gate) or move on to the plan.

  2. 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:

    • Approveassistant:confirm { conversationId, plan? } — send the user's edited plan to build that, or omit it to build the pending plan as-is. The build then streams assistant:tool_start / assistant:tool_result per step (create_object, create_field, create_relation, create_view, seed_sample_data, create_automation, create_folder) and ends with assistant:complete.
    • Revise → send a normal assistant:send with free-text change requests while the plan is pending → the builder re-proposes (another plan gate).
    • Declineassistant:reject { conversationId }.

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):

  1. The turn streams as usual (assistant:agentassistant:token …), then instead of assistant:complete you 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
        }
      ]
    }
  2. Render a confirmation card: the message, then one row per writes[i] showing label (and an icon chosen from action). Show Approve and Decline.
  3. On Approve → send assistant:confirm { conversationId, approvedCallIds: writes.map(w => w.callId) }. The run resumes, performs the approved writes (you'll see assistant:tool_start/tool_result then assistant:complete).
  4. On Decline → send assistant:reject { conversationId }. Nothing is written; the agent acknowledges and assistant:complete follows.

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. approvedCallIds must be the callIds 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 …/messages response carries the same pause as kind: "confirmation" + a gate: { type: "writes", writes: [...] } (the writes shape matches the WS event — callId, action, label, …). Resume with POST /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. whatsapp pins 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 confirmation turn renders its writes[].label list as a numbered WhatsApp message ("Reply yes / no"); the gate's callIds wait in Redis and a bare "yes/نعم" resumes via resumeConfirmationRun with those approvedCallIds — 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 inside TenantScope.runInScope pinned to the admin's workspace. The credit ledger records channel: 'whatsapp'.
  • Flag: rides on AI_V2_ENABLED (plus the channel's own WHATSAPP_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, flag AI_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

FlagGatesDefault
AI_V2_ENABLEDThe chat — REST /ai-v2/* and WS /ai-v2/chatincluding the inline workspace builderoff
AI_V2_ONBOARDING_ENABLEDOnboarding (/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.

On this page