Eloquent

Documentation

<!-- AUTO-GENERATED from packages/@elqnt/chat/SKILL.md — do not edit here. Run `pnpm docs:sync`. -->
npm install @elqnt/chat

Chat — Building a Custom Chat UI

Eloquent's chat service runs conversational threads: it owns chat state, takes the LLM turn for a bound agent, and fans replies back over SSE. This skill is only about one thing: building a custom app that talks to it through the @elqnt/chat package.

Package is @elqnt/chat. It exposes a realtime hook (useChat — POST + SSE, no bearer) and a history hook (useChatHistory/api/v1/chats, bearer JWT). Designing/saving the agent a thread binds to is a different package (@elqnt/agents) with its own skill — cross-linked below, not duplicated here.


⛔ The contract — read this before writing any code

This skill ships inside the package, so the contract is not a separate file to keep in sync — it is the package's own published type declarations (@elqnt/chatdist/**/*.d.ts). Because your app imports the real hooks, the TypeScript compiler enforces the contract for you: a wrong param or a misused return value won't compile.

The single source of truth, in order:

  1. import { useChat, useChatHistory } from "@elqnt/chat/hooks" — the two hooks this skill is about.
  2. import type { UseChatOptions, UseChatReturn, UseChatHistoryOptions, UseChatHistoryReturn, ChatHistoryResult } from "@elqnt/chat/hooks" — the named, exact surface of each hook (every method, its params, its return type). The tables below are a human-readable mirror of these.
  3. import type { Chat, ChatMessage, ChatEvent, ChatEventTypeTS, ChatSummary, ChatRoleTS, MessageStatusTS } from "@elqnt/chat/models" — the DTOs on the wire.
  4. import type { TransportState, TransportError, ConnectionMetrics, ChatTransport } from "@elqnt/chat/transport" — connection state/error/metrics + the transport interface (only if you swap transports).

Rules (do not drift):

  1. Use only the exported hooks and only the methods they expose. Do not invent hooks, methods, params, or return shapes — if it's not on UseChatReturn / UseChatHistoryReturn, it doesn't exist.
  2. Let the compiler check you. Build with tsc; never as any / @ts-ignore your way around a hook's types to force a call that isn't there.
  3. Never bypass the hooks for state. No NATS, no WebSocket, no direct chat service calls. The realtime layer's only raw fetch is the package's own transport (POST /create|/send|..., GET /stream) — you call useChat, not fetch.
  4. Wrap, don't scatter. Mount one useChat instance per thread subtree (a context), so every component shares one messages/connectionState.
  5. If a requirement seems to need a call the package doesn't define, stop and flag it — do not improvise an endpoint.

Architecture — TWO auth models, one path each

The realtime hook and the history hook authorize differently, and realtime is tier 2 (direct to the chat service, bypassing the gateway — see packages/@elqnt/CONVENTIONS.md). Getting this wrong is the #1 chat bug.

┌──────────────────────────────────────────────────────────────────────────────┐
│  YOUR CUSTOM APP (Next.js / React)                                             │
│                                                                                │
│  REALTIME — useChat (tier 2)              HISTORY — useChatHistory (tier 1)    │
│  (@elqnt/chat/hooks)                       (@elqnt/chat/hooks)                  │
│        │  raw fetch (POST) + EventSource         │  browserApiRequest          │
│        │  signed token: ?token= on /stream,      │  Authorization: Bearer <jwt>│
│        │  Authorization: Bearer on POSTs         │  (@elqnt/api-client)        │
│        │  (token from getToken)                  │  baseUrl = gateway ROOT     │
│        │  baseUrl = CHAT_SERVER_BASE_URL         │                             │
└────────┼──────────────────────────────────────────┼─────────────────────────────┘
         ▼  (direct, no gateway)                     ▼
   ┌───────────────┐  validate TOKEN, derive    ┌───────────────┐  verify JWT,
   │  chat (Go)    │  orgId/userId from claims  │  API GATEWAY  │  stamp X-Org-ID
   │ SSE/REST/WS   │  /create /send /stream     │  (JWT verify) │  /api/v1/chats**
   └──────┬────────┘  (origin = secondary)      └──────┬────────┘
          ▼                                            ▼
   ┌────────────────────────────────────────────────────────────┐
   │  chat (Go)  —  threads, LLM turn for the bound agent,       │
   │               SSE fan-out, history/list, memory             │
   └────────────────────────────────────────────────────────────┘
Realtime (useChat, transport, stream-api) — tier 2History (useChatHistory) — tier 1
Transportraw fetch POST + EventSource SSE, direct to the chat servicebrowserApiRequest (@elqnt/api-client) via gateway
Authsigned token?token= on /stream, Authorization: Bearer on POSTs (from getToken). Service derives orgId/userId/product from the token's claims; ?orgId&userId&clientType are routing hints only. Origin is defense-in-depth, not authorization.bearer JWT (Authorization: Bearer …) via the gateway
baseUrlthe chat service endpoint (CHAT_SERVER_BASE_URL), e.g. …/api/v1/chatgateway root (e.g. https://api…)
EndpointsPOST /create /send /load /typing /end /event, GET /stream/api/v1/chats**

Two common mistakes:

  1. Passing the gateway root as useChat's baseUrl. The realtime layer appends /create, /send, /stream directly to baseUrl — so baseUrl must be the full chat prefix (e.g. ${CHAT_SERVER_BASE_URL}…/api/v1/chat).
  2. Omitting the token. The chat service now requires a valid short-lived signed token on every connect/create/send (tier-2 security rule). Pass getToken (see next section). Origin is no longer authorization — a token-less connection is rejected (HTTP 401) when enforcement is on (CHAT_AUTH_ENFORCE=true, the default).

Tier-2 authorization — the token handshake (REQUIRED)

Full reference (backend validator, claim shape, env/kill-switch, rollout, file map): TIER2_AUTH.md.

Because realtime traffic reaches the chat service directly (the gateway — and its JWT check — is not in the path), the service authorizes each connection itself from a short-lived signed token. You supply it via getToken; the package attaches it for you:

  • SSE /stream → as ?token=<jwt> (EventSource can't set headers).
  • POST /create /send /load /typing /end /event → as Authorization: Bearer <jwt>.

The service validates the token (HS256, the shared JWT_SECRET; audience eloquent-api; issuer eloquent-gateway; not expired) and derives orgId / userId / product from its claims — the body/query identity is not trusted. So the token MUST be scoped to the same org/user the hook runs as.

Claim shape (same as the gateway / @elqnt/api-client/server generateServerToken — one signing path serves tier 1 and tier 2):

{
  "org_id":  "<org-uuid>",        // tenant — the security boundary
  "user_id": "<user/visitor id>", // conversation identity
  "product": "eloquent",          // routing
  "scopes":  ["chat:connect"],
  "iss": "eloquent-gateway", "aud": "eloquent-api", "exp": <+1h>
}

Where the token comes from:

SurfacegetToken source
Authenticated app (logged-in user)getGatewayToken from @elqnt/api-client/browser — the session token from /api/gateway-token, scoped to the user's verified org/user.
Anonymous (public widget, marketing demo)A server route / bound server action that mints a token whose org is resolved server-side (from the widgetId or a trusted env), with the visitor id as user_id. Never let the browser pick the org.
import { getGatewayToken } from "@elqnt/api-client/browser";

// Authenticated: pass the session token provider directly.
const chat = useChat({ baseUrl, orgId, userId, getToken: getGatewayToken });

// Anonymous: a server action bound to the TRUSTED org (Next encrypts bound
// args, so the browser can't substitute a different org).
//   actions:  "use server"; export async function mintAnonChatToken(orgId, product, userId) {…}
//   page:     <Widget getChatToken={mintAnonChatToken.bind(null, widget.orgId, "eloquent")} />
//   client:   useChat({ …, getToken: () => getChatToken(visitorId) })

getToken is awaited before every connect and every REST send, so a long-lived stream keeps working as short-lived tokens roll over. A static token string is also accepted but does not refresh. If getToken resolves to nothing, the request goes out token-less and the service rejects it (when enforcing).

Migration: the service has a kill-switch CHAT_AUTH_ENFORCE (default true). Set it to false for a short, coordinated rollout window — tokens are still validated when present, but token-less connections are allowed through and logged, so already-deployed clients keep working until they ship the handshake. Flip back to true (the secure default) once clients are updated.

The mental model for a thread: a thread = a Chat, identified by a chatKey string. You (1) create a chat (POST /create), (2) open the SSE stream (GET /stream), (3) send messages (POST /send). The user message echoes optimistically; the AI reply arrives over SSE as a complete message event, not in the POST response.


POST endpoints (realtime, via gateway /api/v1/chat)

useChat drives these for you; the table is the wire contract behind each method.

OpPathMethodBodyResponse
Create thread/api/v1/chat/createPOST{ orgId, userId, metadata? }{ chatKey }
Send message/api/v1/chat/sendPOST{ orgId, chatKey, userId, message: ChatMessage, data? }{ success: true } (AI reply via SSE)
Load thread/api/v1/chat/loadPOST{ orgId, chatKey, userId }{ chat: Chat }
Typing/api/v1/chat/typingPOST{ orgId, chatKey, userId, typing }{ success }
End/api/v1/chat/endPOST{ orgId, chatKey, userId, data? }{ success }
Generic event/api/v1/chat/eventPOST{ type, orgId, chatKey, userId, data? }{ success }
SSE stream/api/v1/chat/streamGETquery orgId, userId, clientType, chatKey?text/event-stream

/send returns immediately and processes the LLM turn asynchronously — the assistant reply comes back over the SSE stream as a message event. Bind the thread to your saved agent by passing { agentId } (or { agentName }) in startChat's metadata — it's stored on Chat.metadata.

Product routing (multi-product apps). Agent resolution queries the per-product DB agents_<product>. Pass { product: "<your-product>" } in startChat's metadata; if omitted, the chat service falls back to the product claim of the signed tier-2 token (backfilled server-side the same way orgId/userId are). With neither, resolution defaults to agents_eloquent and /create 500s with "agent … not found in agents_eloquent" even though the agent exists in your product's DB — historically bitten by quick-mind and darti-web, which is why the fallback exists. Send the explicit metadata value when you can; it is authoritative over the token claim.


The message wire shape (ChatMessage)

From @elqnt/chat/models:

type ChatRoleTS = 'user'|'ai'|'event'|'humanAgent'|'observer'|'dataQuery'|'system'|'tool';
type MessageStatusTS = 'sending'|'sent'|'delivered'|'read'|'failed';

interface ChatMessage {
  id: string; role: ChatRoleTS; content: string;
  time: number; status: MessageStatusTS;
  senderId: string; senderName?: string;
  createdAt: number; updatedAt?: number;
  replyTo?: string; threadId?: string; mentions?: string[];
  attachments?: Attachment[]; reactions?: EmojiReaction[];
  variables?: Record<string, Variable>;
  name?: string; toolCallId?: string; toolCalls?: ToolCall[];
  llmUsage?: { inputTokens: number; outputTokens: number; totalTokens: number };
  error?: { code?: string; retryable?: boolean };   // set on a failed-turn message
}
interface ToolCall { Name: string; Arguments: Record<string, any>; ID: string; Description: string }

The AI reply arrives as a ChatMessage with role === "ai" inside a message SSE event.


The SSE flow + ChatEvent

The stream is opened with native EventSource in the browser (fetch + ReadableStream fallback for React Native — auto-selected). URL: ${baseUrl}/stream?orgId=…&userId=…&clientType=customer[&chatKey=…]. Wire format:

event: <eventType>\n
data:  <JSON of ChatEvent>\n
\n

Heartbeat every ~30s is an SSE comment (: heartbeat). Every data line is a ChatEvent:

interface ChatEvent {
  type: ChatEventTypeTS;
  orgId: string; chatKey: string; userId: string;
  timestamp: number;
  data?: Record<string, any>;
  message?: ChatMessage;
}

Delivery = whole messages, not token deltas. There is no token/delta event and no isStreaming flag. The backend buffers the LLM turn and pushes one complete message event (message.role === "ai"). Progress is conveyed by named lifecycle events — drive a "thinking" indicator off agent_execution_started / _ended. To interrupt a turn, call stopGeneration() (sends stop_turn; ≥ 3.6.0).

Key server→client event types (ChatEventTypeTS is the full union — these are the ones a custom UI usually handles):

typeMeaning
messageA complete message; event.message holds the ChatMessage (AI reply / tool results). "The assistant said something." useChat auto-appends it to messages.
reconnectedInitial connect handshake (data.connectionType, data.connId).
new_chat_createdAnother tab created a chat (multi-tab sync); useChat adopts data.chatKey.
load_chat_responseA thread was loaded; data.chat is a Chat (multi-tab sync).
agent_execution_started / agent_execution_endedAgent turn boundaries → "thinking" indicator.
step_started / step_completed / step_failed, plan_pending_approval / plan_approved / plan_rejected / plan_completedPlan → Approve → Execute progress.
agent_context_update, summary_update, skills_changedContext / skill changes mid-session.
typing / stopped_typing, human_agent_joined / _left, chat_ended, show_csat_surveyPresence / lifecycle.
attachment_processing_started / _progress / _complete / _errorDeferred document processing on an attachment.
client_action / client_action_callbackClient-side tool round-trip (see platform client-functions skill).
errorFailed turn; data.message. useChat surfaces it on error.
transport_reconnectedClient-synthetic — emitted by the transport after a dropped stream re-establishes, so you can re-sync. Never sent by the server.

useChat handles message, new_chat_created, load_chat_response, chat_ended, and error internally; for everything else subscribe with on(type, handler) or the onMessage callback.


Hook: useChat

import { useChat } from "@elqnt/chat/hooks";

import { getGatewayToken } from "@elqnt/api-client/browser";

const chat = useChat({
  baseUrl: `${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/api/v1/chat`, // MUST be the chat prefix
  orgId, userId, autoConnect: true,
  getToken: getGatewayToken,   // REQUIRED — tier-2 token (see handshake section)
});

Options — UseChatOptions

interface UseChatOptions {
  baseUrl: string;          // MUST be the chat prefix, e.g. ".../api/v1/chat"
  orgId: string;
  userId: string;
  clientType?: "customer" | "humanAgent" | "observer";   // default "customer"
  transport?: ChatTransport | "sse" | "sse-fetch";        // auto-selected by default
  onMessage?: (event: ChatEvent) => void;                 // ALL incoming events
  onError?: (error: TransportError) => void;
  onConnectionChange?: (state: TransportState) => void;
  autoConnect?: boolean;    // default false
  retryConfig?: RetryConfig;
  debug?: boolean;
  // Tier-2 authorization (REQUIRED in production). See the handshake section.
  getToken?: () => Promise<string | null | undefined> | string | null | undefined; // awaited per connect + per send
  token?: string;           // static alternative to getToken (does NOT refresh)
}

Return — UseChatReturn

interface UseChatReturn {
  // Connection
  connect: () => Promise<void>;
  disconnect: () => void;
  connectionState: TransportState;   // 'disconnected'|'connecting'|'connected'|'reconnecting'
  isConnected: boolean;

  // Chat operations (POST under the hood, resolve from the HTTP response)
  startChat: (metadata?: Record<string, unknown>) => Promise<string>;   // → chatKey  (POST /create)
  loadChat:  (chatKey: string) => Promise<Chat>;                        // POST /load
  sendMessage: (content: string, attachments?: unknown[], data?: Record<string, unknown>) => Promise<void>; // POST /send
  sendEvent: (event: Omit<ChatEvent, "timestamp">) => Promise<void>;   // POST /event
  stopGeneration: () => Promise<void>;   // abort the in-flight turn (sends stop_turn); no-op if no chatKey
  endChat: (reason?: string) => Promise<void>;                         // POST /end

  // Typing indicators (fire-and-forget POST /typing)
  startTyping: () => void;
  stopTyping: () => void;

  // State (auto-maintained from SSE)
  currentChat: Chat | null;
  chatKey: string | null;
  messages: ChatMessage[];           // appended from `message` SSE events + optimistic user echo
  error: TransportError | null;
  metrics: ConnectionMetrics;

  // Event subscription
  on: (eventType: string, handler: (event: ChatEvent) => void) => Unsubscribe;
  clearError: () => void;
}

Free behavior: startChat POSTs /create and stores chatKey; the SSE message handler appends to messages; sendMessage optimistically appends the user message then POSTs /send. startChat and loadChat read directly from the HTTP response, so you don't wait on new_chat_created / load_chat_response SSE events (those are multi-tab sync).

Stop button (@elqnt/chat ≥ 3.6.0): stopGeneration() sends a stop_turn event; the chat service cancels the turn's context and discards the partial response (never persisted or broadcast). The hook owns no "generating" flag — derive one yourself (on("agent_execution_started"/"_ended", …), or the last message still being the user's) and show a Stop control while it's true. After calling stopGeneration(), clear that flag optimistically so the typing indicator disappears without waiting for the server's waiting=false broadcast.

baseUrl must be the chat prefix (.../api/v1/chat) and no bearer is sent — the stream is authorized by origin allow-list + ?orgId&userId&clientType.


Hook: useChatHistory (separate auth — bearer JWT)

import { useChatHistory } from "@elqnt/chat/hooks";

const history = useChatHistory({ baseUrl: apiGatewayUrl, orgId }); // gateway ROOT, JWT'd

useChatHistory(options: UseChatHistoryOptions) (= ApiClientOptions) returns UseChatHistoryReturn:

MethodSignatureResolves toEndpoint
getChatHistory(params?: { limit?: number; offset?: number; skipCache?: boolean }) => Promise<ChatHistoryResult>{ chats: [], total: 0, hasMore: false } on errorPOST /api/v1/chats
getChat(chatKey: string) => Promise<ChatSummary | null>nullGET /api/v1/chats/{chatKey}
updateChat(chatKey: string, updates: { title?: string; pinned?: boolean }) => Promise<boolean>true/falsePATCH /api/v1/chats/{chatKey}
deleteChat(chatKey: string) => Promise<boolean>true/falseDELETE /api/v1/chats/{chatKey}
getChatsByUser(userEmail: string) => Promise<ChatSummary[]>[]GET /api/v1/chats/user/{email}
interface ChatHistoryResult { chats: ChatSummary[]; total: number; hasMore: boolean; }
interface ChatSummary {
  chatKey: string; title: string; userId?: string;
  status: string; lastUpdated: number;
  waitingSince?: number; pinned?: boolean;
  metadata?: Record<string, any>; isBackground?: boolean;
}

History verbs don't throw. Like the entity hooks, they're built on useApiAsync: each resolves to its default and sets the aggregate error on failure. Inspect history.error / the returned value — don't wrap in try/catch expecting a throw. getChat / getChatsByUser return a ChatSummary (lightweight list shape), not a full Chat — load the full thread (with messages) via useChat().loadChat(chatKey).


Thread / conversation model (Chat)

interface Chat {
  orgId: string;
  key: string;                          // key = chatKey, the thread id
  title: string;
  messages: ChatMessage[];
  startTime: number; lastUpdated: number;
  users: ChatUser[];
  status: 'active'|'disconnected'|'abandoned'|'closed'|'archived'|'completed';
  aiEngaged: boolean; humanAgentEngaged: boolean;
  isWaiting: boolean; isWaitingForAgent: boolean;
  metadata?: Record<string, any>;       // holds the agentId/agentName binding
  activeSkillIds?: string[]; deactivatedSkillIds?: string[];   // mid-session skill toggles
  agentContextKey?: string;             // "{agentId}:{chatKey}" in NATS KV
  agentContext?: unknown;               // AI-Brain state, inlined on loadChat — see below
  pinned?: boolean;
  chatType?: 'customer_support'|'public_room'|'private_room'|'direct'|'group';
  // …plus grading/flow/context/csat fields
}

Created via POST /api/v1/chat/createchatKey (useChat().startChat). Referenced by chatKey thereafter. The lightweight list shape returned by the history hook is ChatSummary (above); the full thread with messages comes from useChat().loadChat(chatKey) (or POST /api/v1/chat/load).

Agent execution context — the "AI Brain" (chat.agentContext)

An agent's tool executions + plan (the "AI Brain" / ACTIONS panel) live in a separate NATS KV bucket, pointed at by agentContextKey. To render that panel you need the resolved state, and there are two delivery paths — use both:

  1. Seed on load (snapshot). loadChat(chatKey) returns the chat with the fully-resolved context inlined at chat.agentContext — the chat service resolves it server-side and includes it in the authenticated /load response. Set your panel state from this once, alongside the messages. It is typed unknown here because the concrete AgentContext type lives in @elqnt/agents (this package doesn't depend on it) — cast it: const ctx = chat.agentContext as AgentContext | null.
  2. Live updates (delta). While a turn runs, the SSE events agent_execution_started, agent_execution_ended, and agent_context_update append/update executions in real time. Merge these onto the seeded snapshot, keyed by execution.id (append if new, replace if the id exists). Plan events: plan_pending_approval, step_started/step_completed/step_failed, plan_completed.

Why this shape (do not regress it): the context used to be fetched only via a separate realtime request fired right after loadChat. On a fresh page reload that request raced the not-yet-open SSE connection and was lost, so the panel showed empty until the next turn. Seeding from the authenticated /load response (path 1) is HTTP — it can't race and can't 401. Do not reintroduce a load_agent_context realtime round-trip for the initial fetch.


Minimal end-to-end chat

"use client";
import { useChat } from "@elqnt/chat/hooks";
import { getGatewayToken } from "@elqnt/api-client/browser";
import { useEffect, useState } from "react";

export function ChatPanel({ orgId, userId, agentName }: {
  orgId: string; userId: string; agentName: string;
}) {
  const chat = useChat({
    baseUrl: `${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/api/v1/chat`, // chat prefix, NOT gateway root
    orgId, userId, autoConnect: true,
    getToken: getGatewayToken,   // tier-2 token — the service rejects token-less connects
  });
  const [thinking, setThinking] = useState(false);

  useEffect(() => {
    chat.connect();
    return () => chat.disconnect();
  }, []); // eslint-disable-line

  useEffect(() => chat.on("agent_execution_started", () => setThinking(true)), [chat.on]);
  useEffect(() => chat.on("agent_execution_ended",   () => setThinking(false)), [chat.on]);

  async function begin() {
    await chat.startChat({ agentName });   // binds thread → a saved agent (see @elqnt/agents)
  }

  return (
    <div>
      <button onClick={begin} disabled={!chat.isConnected}>Start</button>
      <ul>
        {chat.messages.map((m) => (
          <li key={m.id}><b>{m.role}:</b> {m.content}</li>
        ))}
      </ul>
      {thinking && <p>…thinking</p>}
      <button onClick={() => chat.sendMessage("What's your refund policy?")}>Ask</button>
    </div>
  );
}

Wrap one useChat instance per thread subtree in a context so every component shares one messages / connectionState rather than re-connecting N times — the same "one shared hook instance" pattern the entities skill uses for its domain context.


Gotchas

  • Two auth models — don't cross the wires. Realtime (useChat, transport, stream-api) is tier 2 (direct to the chat service) → raw fetch + EventSource, authorized by a signed token from getToken (?token= on /stream, Authorization: Bearer on POSTs), baseUrl = the chat service endpoint (.../api/v1/chat). History (useChatHistory) is tier 1@elqnt/api-client bearer JWT through the gateway, baseUrl = gateway root. Passing the gateway root to useChat (or the chat prefix to useChatHistory) is the classic failure.
  • getToken is required. The chat service derives orgId/userId/product from the token's claims and rejects a token-less connect with 401 (when CHAT_AUTH_ENFORCE=true, the default). The query ?orgId&userId&clientType are routing hints, not identity. Origin is defense-in-depth only — it no longer authorizes, and a missing Origin is no longer auto-allowed. Scope the token to the same org/user the hook runs as. See the handshake section.
  • No token streaming. Replies arrive as complete message SSE events. No delta/token, no isStreaming flag. Use agent_execution_started/_ended for a "thinking" UI, and stopGeneration() (≥ 3.6.0) to interrupt a turn.
  • /send is async. It returns { success: true } immediately; the AI reply lands later as a message event over SSE. Don't read the reply from the POST.
  • startChat/loadChat resolve from HTTP. You don't need to await new_chat_created / load_chat_response SSE events — those are multi-tab sync only.
  • History returns ChatSummary, not Chat. getChat / getChatsByUser give the lightweight list shape (no messages). Load the full thread with useChat().loadChat(chatKey).
  • History verbs don't throw. They resolve to defaults and set error (useApiAsync). Check error / the result; don't try/catch for throws.
  • Bind agent → thread via startChat({ agentId }) (or agentName for environment portability — ids differ per environment). The binding lives on Chat.metadata.
  • One useChat per thread. Mount it once in a context; don't call it in every component (each call opens its own connection + messages).
  • Seed the AI-Brain panel from chat.agentContext on load, not a realtime fetch. loadChat inlines the resolved agent context; setting your panel state from it (then merging the live agent_execution_* SSE events by execution.id) is race-free. A separate load_agent_context round-trip is lost on reload before the SSE channel is ready — that was the "AI Brain empty after refresh" bug. See the Agent execution context section.

Related skills

  • agents (@elqnt/agents) — design/save the agent a thread binds to (LLM config, skills, tools, csat/handoff). This skill is chat-only; the agent half lives there.
  • entities (@elqnt/entity) — drive the entities backend the same way (hooks → gateway), including the shared "domain layer + one shared hook instance" pattern.
  • (Platform) client-functions — the client-side tool round-trip (client_action SSE event → widget runs a flow → POST a role:"tool" result to resume the turn).