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/chat → dist/**/*.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:
import { useChat, useChatHistory } from "@elqnt/chat/hooks"— the two hooks this skill is about.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.import type { Chat, ChatMessage, ChatEvent, ChatEventTypeTS, ChatSummary, ChatRoleTS, MessageStatusTS } from "@elqnt/chat/models"— the DTOs on the wire.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):
- 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. - Let the compiler check you. Build with
tsc; neveras any/@ts-ignoreyour way around a hook's types to force a call that isn't there. - Never bypass the hooks for state. No NATS, no WebSocket, no direct chat
service calls. The realtime layer's only raw
fetchis the package's own transport (POST /create|/send|...,GET /stream) — you calluseChat, notfetch. - Wrap, don't scatter. Mount one
useChatinstance per thread subtree (a context), so every component shares onemessages/connectionState. - 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 2 | History (useChatHistory) — tier 1 | |
|---|---|---|
| Transport | raw fetch POST + EventSource SSE, direct to the chat service | browserApiRequest (@elqnt/api-client) via gateway |
| Auth | signed 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 |
baseUrl | the chat service endpoint (CHAT_SERVER_BASE_URL), e.g. …/api/v1/chat | gateway root (e.g. https://api…) |
| Endpoints | POST /create /send /load /typing /end /event, GET /stream | /api/v1/chats** |
Two common mistakes:
- Passing the gateway root as
useChat'sbaseUrl. The realtime layer appends/create,/send,/streamdirectly tobaseUrl— sobaseUrlmust be the full chat prefix (e.g.${CHAT_SERVER_BASE_URL}→…/api/v1/chat).- 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→ asAuthorization: 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:
| Surface | getToken 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(defaulttrue). Set it tofalsefor 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 totrue(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.
| Op | Path | Method | Body | Response |
|---|---|---|---|---|
| Create thread | /api/v1/chat/create | POST | { orgId, userId, metadata? } | { chatKey } |
| Send message | /api/v1/chat/send | POST | { orgId, chatKey, userId, message: ChatMessage, data? } | { success: true } (AI reply via SSE) |
| Load thread | /api/v1/chat/load | POST | { orgId, chatKey, userId } | { chat: Chat } |
| Typing | /api/v1/chat/typing | POST | { orgId, chatKey, userId, typing } | { success } |
| End | /api/v1/chat/end | POST | { orgId, chatKey, userId, data? } | { success } |
| Generic event | /api/v1/chat/event | POST | { type, orgId, chatKey, userId, data? } | { success } |
| SSE stream | /api/v1/chat/stream | GET | query 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/deltaevent and noisStreamingflag. The backend buffers the LLM turn and pushes one completemessageevent (message.role === "ai"). Progress is conveyed by named lifecycle events — drive a "thinking" indicator offagent_execution_started/_ended. To interrupt a turn, callstopGeneration()(sendsstop_turn; ≥ 3.6.0).
Key server→client event types (ChatEventTypeTS is the full union — these are
the ones a custom UI usually handles):
type | Meaning |
|---|---|
message | A complete message; event.message holds the ChatMessage (AI reply / tool results). "The assistant said something." useChat auto-appends it to messages. |
reconnected | Initial connect handshake (data.connectionType, data.connId). |
new_chat_created | Another tab created a chat (multi-tab sync); useChat adopts data.chatKey. |
load_chat_response | A thread was loaded; data.chat is a Chat (multi-tab sync). |
agent_execution_started / agent_execution_ended | Agent turn boundaries → "thinking" indicator. |
step_started / step_completed / step_failed, plan_pending_approval / plan_approved / plan_rejected / plan_completed | Plan → Approve → Execute progress. |
agent_context_update, summary_update, skills_changed | Context / skill changes mid-session. |
typing / stopped_typing, human_agent_joined / _left, chat_ended, show_csat_survey | Presence / lifecycle. |
attachment_processing_started / _progress / _complete / _error | Deferred document processing on an attachment. |
client_action / client_action_callback | Client-side tool round-trip (see platform client-functions skill). |
error | Failed turn; data.message. useChat surfaces it on error. |
transport_reconnected | Client-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.
baseUrlmust 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:
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
getChatHistory | (params?: { limit?: number; offset?: number; skipCache?: boolean }) => Promise<ChatHistoryResult> | { chats: [], total: 0, hasMore: false } on error | POST /api/v1/chats |
getChat | (chatKey: string) => Promise<ChatSummary | null> | null | GET /api/v1/chats/{chatKey} |
updateChat | (chatKey: string, updates: { title?: string; pinned?: boolean }) => Promise<boolean> | true/false | PATCH /api/v1/chats/{chatKey} |
deleteChat | (chatKey: string) => Promise<boolean> | true/false | DELETE /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 aggregateerroron failure. Inspecthistory.error/ the returned value — don't wrap in try/catch expecting a throw.getChat/getChatsByUserreturn aChatSummary(lightweight list shape), not a fullChat— load the full thread (withmessages) viauseChat().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/create → chatKey (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:
- Seed on load (snapshot).
loadChat(chatKey)returns the chat with the fully-resolved context inlined atchat.agentContext— the chat service resolves it server-side and includes it in the authenticated/loadresponse. Set your panel state from this once, alongside the messages. It is typedunknownhere because the concreteAgentContexttype lives in@elqnt/agents(this package doesn't depend on it) — cast it:const ctx = chat.agentContext as AgentContext | null. - Live updates (delta). While a turn runs, the SSE events
agent_execution_started,agent_execution_ended, andagent_context_updateappend/update executions in real time. Merge these onto the seeded snapshot, keyed byexecution.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/loadresponse (path 1) is HTTP — it can't race and can't 401. Do not reintroduce aload_agent_contextrealtime 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) → rawfetch+EventSource, authorized by a signed token fromgetToken(?token=on/stream,Authorization: Beareron POSTs),baseUrl= the chat service endpoint (.../api/v1/chat). History (useChatHistory) is tier 1 →@elqnt/api-clientbearer JWT through the gateway,baseUrl= gateway root. Passing the gateway root touseChat(or the chat prefix touseChatHistory) is the classic failure. getTokenis required. The chat service derivesorgId/userId/productfrom the token's claims and rejects a token-less connect with 401 (whenCHAT_AUTH_ENFORCE=true, the default). The query?orgId&userId&clientTypeare routing hints, not identity. Origin is defense-in-depth only — it no longer authorizes, and a missingOriginis 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
messageSSE events. Nodelta/token, noisStreamingflag. Useagent_execution_started/_endedfor a "thinking" UI, andstopGeneration()(≥ 3.6.0) to interrupt a turn. /sendis async. It returns{ success: true }immediately; the AI reply lands later as amessageevent over SSE. Don't read the reply from the POST.startChat/loadChatresolve from HTTP. You don't need to awaitnew_chat_created/load_chat_responseSSE events — those are multi-tab sync only.- History returns
ChatSummary, notChat.getChat/getChatsByUsergive the lightweight list shape (nomessages). Load the full thread withuseChat().loadChat(chatKey). - History verbs don't throw. They resolve to defaults and set
error(useApiAsync). Checkerror/ the result; don't try/catch for throws. - Bind agent → thread via
startChat({ agentId })(oragentNamefor environment portability — ids differ per environment). The binding lives onChat.metadata. - One
useChatper 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.agentContexton load, not a realtime fetch.loadChatinlines the resolved agent context; setting your panel state from it (then merging the liveagent_execution_*SSE events byexecution.id) is race-free. A separateload_agent_contextround-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_actionSSE event → widget runs a flow → POST arole:"tool"result to resume the turn).