@elqnt/chat 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @elqnt/chat
2
2
 
3
+ > **Building a chat UI with an Eloquent agent? Read [`SKILL.md`](./SKILL.md) first.**
4
+ > It is the agent-facing guide: the two auth models (realtime origin-allow-list vs.
5
+ > history bearer JWT), the POST + SSE wire contract, the `ChatEvent` catalog, and the
6
+ > exact `useChat` / `useChatHistory` typed return surfaces. The contract is the
7
+ > package's shipped `.d.ts` — import the named return + model types and let `tsc` enforce it.
8
+
3
9
  Platform-agnostic chat SDK for React and React Native. Uses HTTP + SSE for reliable real-time communication.
4
10
 
5
11
  **Key Features:**
package/SKILL.md ADDED
@@ -0,0 +1,531 @@
1
+ ---
2
+ name: chat
3
+ description: Build a custom chat UI on the Eloquent chat backend using the @elqnt/chat hooks. Covers the realtime path (custom app → useChat → POST /create /send + SSE /stream → chat Go service) and the history path (useChatHistory → /api/v1/chats), the TWO different auth models (realtime is tier-2 direct: a short-lived signed token on connect/create/send via getToken — origin is only defense-in-depth; history is a bearer JWT through the gateway), the tier-2 token handshake (getToken option, ?token= on the SSE stream, Authorization: Bearer on POSTs, claim shape), the POST endpoints + bodies, the SSE ChatEvent catalog, the thread/chatKey model, and the EXACT input/output spec of useChat + useChatHistory plus the Chat/ChatMessage/ChatEvent types.
4
+ ---
5
+
6
+ # Chat — Building a Custom Chat UI
7
+
8
+ Eloquent's **chat** service runs conversational threads: it owns chat state,
9
+ takes the LLM turn for a bound agent, and fans replies back over SSE. This skill
10
+ is **only** about one thing: building a custom app that talks to it through the
11
+ `@elqnt/chat` package.
12
+
13
+ > Package is `@elqnt/chat`. It exposes a **realtime** hook (`useChat` — POST +
14
+ > SSE, no bearer) and a **history** hook (`useChatHistory` — `/api/v1/chats`,
15
+ > bearer JWT). **Designing/saving the agent** a thread binds to is a *different*
16
+ > package (`@elqnt/agents`) with its own skill — cross-linked below, not
17
+ > duplicated here.
18
+
19
+ ---
20
+
21
+ ## ⛔ The contract — read this before writing any code
22
+
23
+ This skill ships **inside the package**, so the contract is not a separate file
24
+ to keep in sync — it **is** the package's own published type declarations
25
+ (`@elqnt/chat` → `dist/**/*.d.ts`). Because your app *imports the real hooks*,
26
+ the TypeScript compiler enforces the contract for you: a wrong param or a misused
27
+ return value won't compile.
28
+
29
+ The single source of truth, in order:
30
+
31
+ 1. `import { useChat, useChatHistory } from "@elqnt/chat/hooks"` — the two hooks
32
+ this skill is about.
33
+ 2. `import type { UseChatOptions, UseChatReturn, UseChatHistoryOptions, UseChatHistoryReturn, ChatHistoryResult } from "@elqnt/chat/hooks"`
34
+ — the **named, exact** surface of each hook (every method, its params, its
35
+ return type). The tables below are a human-readable mirror of these.
36
+ 3. `import type { Chat, ChatMessage, ChatEvent, ChatEventTypeTS, ChatSummary, ChatRoleTS, MessageStatusTS } from "@elqnt/chat/models"`
37
+ — the DTOs on the wire.
38
+ 4. `import type { TransportState, TransportError, ConnectionMetrics, ChatTransport } from "@elqnt/chat/transport"`
39
+ — connection state/error/metrics + the transport interface (only if you swap
40
+ transports).
41
+
42
+ **Rules (do not drift):**
43
+
44
+ 1. **Use only the exported hooks** and only the methods they expose. Do **not**
45
+ invent hooks, methods, params, or return shapes — if it's not on
46
+ `UseChatReturn` / `UseChatHistoryReturn`, it doesn't exist.
47
+ 2. **Let the compiler check you.** Build with `tsc`; never `as any` /
48
+ `@ts-ignore` your way around a hook's types to force a call that isn't there.
49
+ 3. **Never bypass the hooks for state.** No NATS, no WebSocket, no direct chat
50
+ service calls. The realtime layer's *only* raw `fetch` is the package's own
51
+ transport (`POST /create|/send|...`, `GET /stream`) — you call `useChat`, not
52
+ `fetch`.
53
+ 4. **Wrap, don't scatter.** Mount one `useChat` instance per thread subtree (a
54
+ context), so every component shares one `messages`/`connectionState`.
55
+ 5. If a requirement seems to need a call the package doesn't define, **stop and
56
+ flag it** — do not improvise an endpoint.
57
+
58
+ ---
59
+
60
+ ## Architecture — TWO auth models, one path each
61
+
62
+ The realtime hook and the history hook authorize **differently**, and realtime
63
+ is **tier 2** (direct to the chat service, bypassing the gateway — see
64
+ `packages/@elqnt/CONVENTIONS.md`). Getting this wrong is the #1 chat bug.
65
+
66
+ ```
67
+ ┌──────────────────────────────────────────────────────────────────────────────┐
68
+ │ YOUR CUSTOM APP (Next.js / React) │
69
+ │ │
70
+ │ REALTIME — useChat (tier 2) HISTORY — useChatHistory (tier 1) │
71
+ │ (@elqnt/chat/hooks) (@elqnt/chat/hooks) │
72
+ │ │ raw fetch (POST) + EventSource │ browserApiRequest │
73
+ │ │ signed token: ?token= on /stream, │ Authorization: Bearer <jwt>│
74
+ │ │ Authorization: Bearer on POSTs │ (@elqnt/api-client) │
75
+ │ │ (token from getToken) │ baseUrl = gateway ROOT │
76
+ │ │ baseUrl = CHAT_SERVER_BASE_URL │ │
77
+ └────────┼──────────────────────────────────────────┼─────────────────────────────┘
78
+ ▼ (direct, no gateway) ▼
79
+ ┌───────────────┐ validate TOKEN, derive ┌───────────────┐ verify JWT,
80
+ │ chat (Go) │ orgId/userId from claims │ API GATEWAY │ stamp X-Org-ID
81
+ │ SSE/REST/WS │ /create /send /stream │ (JWT verify) │ /api/v1/chats**
82
+ └──────┬────────┘ (origin = secondary) └──────┬────────┘
83
+ ▼ ▼
84
+ ┌────────────────────────────────────────────────────────────┐
85
+ │ chat (Go) — threads, LLM turn for the bound agent, │
86
+ │ SSE fan-out, history/list, memory │
87
+ └────────────────────────────────────────────────────────────┘
88
+ ```
89
+
90
+ | | **Realtime** (`useChat`, transport, stream-api) — tier 2 | **History** (`useChatHistory`) — tier 1 |
91
+ |---|---|---|
92
+ | Transport | raw `fetch` POST + `EventSource` SSE, **direct to the chat service** | `browserApiRequest` (`@elqnt/api-client`) via gateway |
93
+ | 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 |
94
+ | `baseUrl` | the **chat service endpoint** (`CHAT_SERVER_BASE_URL`), e.g. `…/api/v1/chat` | gateway **root** (e.g. `https://api…`) |
95
+ | Endpoints | `POST /create /send /load /typing /end /event`, `GET /stream` | `/api/v1/chats**` |
96
+
97
+ > **Two common mistakes:**
98
+ > 1. Passing the gateway root as `useChat`'s `baseUrl`. The realtime layer
99
+ > appends `/create`, `/send`, `/stream` directly to `baseUrl` — so `baseUrl`
100
+ > **must** be the full chat prefix (e.g. `${CHAT_SERVER_BASE_URL}` →
101
+ > `…/api/v1/chat`).
102
+ > 2. **Omitting the token.** The chat service now **requires** a valid
103
+ > short-lived signed token on every connect/create/send (tier-2 security
104
+ > rule). Pass `getToken` (see next section). Origin is no longer
105
+ > authorization — a token-less connection is rejected (HTTP 401) when
106
+ > enforcement is on (`CHAT_AUTH_ENFORCE=true`, the default).
107
+
108
+ ---
109
+
110
+ ## Tier-2 authorization — the token handshake (REQUIRED)
111
+
112
+ > Full reference (backend validator, claim shape, env/kill-switch, rollout,
113
+ > file map): [`TIER2_AUTH.md`](./TIER2_AUTH.md).
114
+
115
+ Because realtime traffic reaches the chat service **directly** (the gateway —
116
+ and its JWT check — is not in the path), the **service authorizes each
117
+ connection itself** from a short-lived signed token. You supply it via
118
+ `getToken`; the package attaches it for you:
119
+
120
+ - **SSE `/stream`** → as `?token=<jwt>` (EventSource can't set headers).
121
+ - **POST `/create` `/send` `/load` `/typing` `/end` `/event`** → as
122
+ `Authorization: Bearer <jwt>`.
123
+
124
+ The service validates the token (HS256, the shared `JWT_SECRET`; audience
125
+ `eloquent-api`; issuer `eloquent-gateway`; not expired) and **derives
126
+ `orgId` / `userId` / `product` from its claims** — the body/query identity is
127
+ not trusted. So the token MUST be scoped to the same org/user the hook runs as.
128
+
129
+ **Claim shape** (same as the gateway / `@elqnt/api-client/server`
130
+ `generateServerToken` — one signing path serves tier 1 and tier 2):
131
+
132
+ ```jsonc
133
+ {
134
+ "org_id": "<org-uuid>", // tenant — the security boundary
135
+ "user_id": "<user/visitor id>", // conversation identity
136
+ "product": "eloquent", // routing
137
+ "scopes": ["chat:connect"],
138
+ "iss": "eloquent-gateway", "aud": "eloquent-api", "exp": <+1h>
139
+ }
140
+ ```
141
+
142
+ **Where the token comes from:**
143
+
144
+ | Surface | `getToken` source |
145
+ |---|---|
146
+ | 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. |
147
+ | 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. |
148
+
149
+ ```tsx
150
+ import { getGatewayToken } from "@elqnt/api-client/browser";
151
+
152
+ // Authenticated: pass the session token provider directly.
153
+ const chat = useChat({ baseUrl, orgId, userId, getToken: getGatewayToken });
154
+
155
+ // Anonymous: a server action bound to the TRUSTED org (Next encrypts bound
156
+ // args, so the browser can't substitute a different org).
157
+ // actions: "use server"; export async function mintAnonChatToken(orgId, product, userId) {…}
158
+ // page: <Widget getChatToken={mintAnonChatToken.bind(null, widget.orgId, "eloquent")} />
159
+ // client: useChat({ …, getToken: () => getChatToken(visitorId) })
160
+ ```
161
+
162
+ `getToken` is awaited **before every connect and every REST send**, so a
163
+ long-lived stream keeps working as short-lived tokens roll over. A static
164
+ `token` string is also accepted but does not refresh. If `getToken` resolves to
165
+ nothing, the request goes out token-less and the service rejects it (when
166
+ enforcing).
167
+
168
+ > **Migration:** the service has a kill-switch `CHAT_AUTH_ENFORCE` (default
169
+ > `true`). Set it to `false` for a short, coordinated rollout window — tokens
170
+ > are still validated when present, but token-less connections are allowed
171
+ > through and logged, so already-deployed clients keep working until they ship
172
+ > the handshake. Flip back to `true` (the secure default) once clients are
173
+ > updated.
174
+
175
+ The mental model for a thread: **a thread = a `Chat`, identified by a `chatKey`
176
+ string.** You (1) create a chat (POST `/create`), (2) open the SSE stream (GET
177
+ `/stream`), (3) send messages (POST `/send`). The user message echoes
178
+ optimistically; the **AI reply arrives over SSE as a complete `message` event**,
179
+ not in the POST response.
180
+
181
+ ---
182
+
183
+ ## POST endpoints (realtime, via gateway `/api/v1/chat`)
184
+
185
+ `useChat` drives these for you; the table is the wire contract behind each method.
186
+
187
+ | Op | Path | Method | Body | Response |
188
+ |---|---|---|---|---|
189
+ | Create thread | `/api/v1/chat/create` | POST | `{ orgId, userId, metadata? }` | `{ chatKey }` |
190
+ | Send message | `/api/v1/chat/send` | POST | `{ orgId, chatKey, userId, message: ChatMessage, data? }` | `{ success: true }` (AI reply via SSE) |
191
+ | Load thread | `/api/v1/chat/load` | POST | `{ orgId, chatKey, userId }` | `{ chat: Chat }` |
192
+ | Typing | `/api/v1/chat/typing` | POST | `{ orgId, chatKey, userId, typing }` | `{ success }` |
193
+ | End | `/api/v1/chat/end` | POST | `{ orgId, chatKey, userId, data? }` | `{ success }` |
194
+ | Generic event | `/api/v1/chat/event` | POST | `{ type, orgId, chatKey, userId, data? }` | `{ success }` |
195
+ | SSE stream | `/api/v1/chat/stream` | GET | query `orgId, userId, clientType, chatKey?` | `text/event-stream` |
196
+
197
+ `/send` returns immediately and processes the LLM turn **asynchronously** — the
198
+ assistant reply comes back over the SSE stream as a `message` event. Bind the
199
+ thread to your saved agent by passing `{ agentId }` (or `{ agentName }`) in
200
+ `startChat`'s `metadata` — it's stored on `Chat.metadata`.
201
+
202
+ ---
203
+
204
+ ## The message wire shape (`ChatMessage`)
205
+
206
+ From `@elqnt/chat/models`:
207
+
208
+ ```ts
209
+ type ChatRoleTS = 'user'|'ai'|'event'|'humanAgent'|'observer'|'dataQuery'|'system'|'tool';
210
+ type MessageStatusTS = 'sending'|'sent'|'delivered'|'read'|'failed';
211
+
212
+ interface ChatMessage {
213
+ id: string; role: ChatRoleTS; content: string;
214
+ time: number; status: MessageStatusTS;
215
+ senderId: string; senderName?: string;
216
+ createdAt: number; updatedAt?: number;
217
+ replyTo?: string; threadId?: string; mentions?: string[];
218
+ attachments?: Attachment[]; reactions?: EmojiReaction[];
219
+ variables?: Record<string, Variable>;
220
+ name?: string; toolCallId?: string; toolCalls?: ToolCall[];
221
+ llmUsage?: { inputTokens: number; outputTokens: number; totalTokens: number };
222
+ error?: { code?: string; retryable?: boolean }; // set on a failed-turn message
223
+ }
224
+ interface ToolCall { Name: string; Arguments: Record<string, any>; ID: string; Description: string }
225
+ ```
226
+
227
+ The AI reply arrives as a `ChatMessage` with `role === "ai"` inside a `message`
228
+ SSE event.
229
+
230
+ ---
231
+
232
+ ## The SSE flow + `ChatEvent`
233
+
234
+ The stream is opened with native **`EventSource`** in the browser (`fetch` +
235
+ `ReadableStream` fallback for React Native — auto-selected). URL:
236
+ `${baseUrl}/stream?orgId=…&userId=…&clientType=customer[&chatKey=…]`. Wire format:
237
+
238
+ ```
239
+ event: <eventType>\n
240
+ data: <JSON of ChatEvent>\n
241
+ \n
242
+ ```
243
+
244
+ Heartbeat every ~30s is an SSE comment (`: heartbeat`). Every data line is a
245
+ `ChatEvent`:
246
+
247
+ ```ts
248
+ interface ChatEvent {
249
+ type: ChatEventTypeTS;
250
+ orgId: string; chatKey: string; userId: string;
251
+ timestamp: number;
252
+ data?: Record<string, any>;
253
+ message?: ChatMessage;
254
+ }
255
+ ```
256
+
257
+ > **Delivery = whole messages, not token deltas.** There is **no `token`/`delta`
258
+ > event** and **no `isStreaming`/`stop()`**. The backend buffers the LLM turn and
259
+ > pushes one complete `message` event (`message.role === "ai"`). Progress is
260
+ > conveyed by named lifecycle events — drive a "thinking" indicator off
261
+ > `agent_execution_started` / `_ended`.
262
+
263
+ Key server→client event types (`ChatEventTypeTS` is the full union — these are
264
+ the ones a custom UI usually handles):
265
+
266
+ | `type` | Meaning |
267
+ |---|---|
268
+ | `message` | A complete message; `event.message` holds the `ChatMessage` (AI reply / tool results). **"The assistant said something."** `useChat` auto-appends it to `messages`. |
269
+ | `reconnected` | Initial connect handshake (`data.connectionType`, `data.connId`). |
270
+ | `new_chat_created` | Another tab created a chat (multi-tab sync); `useChat` adopts `data.chatKey`. |
271
+ | `load_chat_response` | A thread was loaded; `data.chat` is a `Chat` (multi-tab sync). |
272
+ | `agent_execution_started` / `agent_execution_ended` | Agent turn boundaries → "thinking" indicator. |
273
+ | `step_started` / `step_completed` / `step_failed`, `plan_pending_approval` / `plan_approved` / `plan_rejected` / `plan_completed` | Plan → Approve → Execute progress. |
274
+ | `agent_context_update`, `summary_update`, `skills_changed` | Context / skill changes mid-session. |
275
+ | `typing` / `stopped_typing`, `human_agent_joined` / `_left`, `chat_ended`, `show_csat_survey` | Presence / lifecycle. |
276
+ | `attachment_processing_started` / `_progress` / `_complete` / `_error` | Deferred document processing on an attachment. |
277
+ | `client_action` / `client_action_callback` | Client-side tool round-trip (see platform client-functions skill). |
278
+ | `error` | Failed turn; `data.message`. `useChat` surfaces it on `error`. |
279
+ | `transport_reconnected` | **Client-synthetic** — emitted by the transport after a dropped stream re-establishes, so you can re-sync. Never sent by the server. |
280
+
281
+ `useChat` handles `message`, `new_chat_created`, `load_chat_response`,
282
+ `chat_ended`, and `error` internally; for everything else subscribe with
283
+ `on(type, handler)` or the `onMessage` callback.
284
+
285
+ ---
286
+
287
+ ## Hook: `useChat`
288
+
289
+ ```ts
290
+ import { useChat } from "@elqnt/chat/hooks";
291
+
292
+ import { getGatewayToken } from "@elqnt/api-client/browser";
293
+
294
+ const chat = useChat({
295
+ baseUrl: `${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/api/v1/chat`, // MUST be the chat prefix
296
+ orgId, userId, autoConnect: true,
297
+ getToken: getGatewayToken, // REQUIRED — tier-2 token (see handshake section)
298
+ });
299
+ ```
300
+
301
+ ### Options — `UseChatOptions`
302
+
303
+ ```ts
304
+ interface UseChatOptions {
305
+ baseUrl: string; // MUST be the chat prefix, e.g. ".../api/v1/chat"
306
+ orgId: string;
307
+ userId: string;
308
+ clientType?: "customer" | "humanAgent" | "observer"; // default "customer"
309
+ transport?: ChatTransport | "sse" | "sse-fetch"; // auto-selected by default
310
+ onMessage?: (event: ChatEvent) => void; // ALL incoming events
311
+ onError?: (error: TransportError) => void;
312
+ onConnectionChange?: (state: TransportState) => void;
313
+ autoConnect?: boolean; // default false
314
+ retryConfig?: RetryConfig;
315
+ debug?: boolean;
316
+ // Tier-2 authorization (REQUIRED in production). See the handshake section.
317
+ getToken?: () => Promise<string | null | undefined> | string | null | undefined; // awaited per connect + per send
318
+ token?: string; // static alternative to getToken (does NOT refresh)
319
+ }
320
+ ```
321
+
322
+ ### Return — `UseChatReturn`
323
+
324
+ ```ts
325
+ interface UseChatReturn {
326
+ // Connection
327
+ connect: () => Promise<void>;
328
+ disconnect: () => void;
329
+ connectionState: TransportState; // 'disconnected'|'connecting'|'connected'|'reconnecting'
330
+ isConnected: boolean;
331
+
332
+ // Chat operations (POST under the hood, resolve from the HTTP response)
333
+ startChat: (metadata?: Record<string, unknown>) => Promise<string>; // → chatKey (POST /create)
334
+ loadChat: (chatKey: string) => Promise<Chat>; // POST /load
335
+ sendMessage: (content: string, attachments?: unknown[], data?: Record<string, unknown>) => Promise<void>; // POST /send
336
+ sendEvent: (event: Omit<ChatEvent, "timestamp">) => Promise<void>; // POST /event
337
+ endChat: (reason?: string) => Promise<void>; // POST /end
338
+
339
+ // Typing indicators (fire-and-forget POST /typing)
340
+ startTyping: () => void;
341
+ stopTyping: () => void;
342
+
343
+ // State (auto-maintained from SSE)
344
+ currentChat: Chat | null;
345
+ chatKey: string | null;
346
+ messages: ChatMessage[]; // appended from `message` SSE events + optimistic user echo
347
+ error: TransportError | null;
348
+ metrics: ConnectionMetrics;
349
+
350
+ // Event subscription
351
+ on: (eventType: string, handler: (event: ChatEvent) => void) => Unsubscribe;
352
+ clearError: () => void;
353
+ }
354
+ ```
355
+
356
+ Free behavior: `startChat` POSTs `/create` and stores `chatKey`; the SSE
357
+ `message` handler appends to `messages`; `sendMessage` optimistically appends the
358
+ user message then POSTs `/send`. There is **no `isStreaming`/`stop()`** — use
359
+ `on("agent_execution_started"/"_ended", …)` for a typing indicator. `startChat`
360
+ and `loadChat` read directly from the HTTP response, so you don't wait on
361
+ `new_chat_created` / `load_chat_response` SSE events (those are multi-tab sync).
362
+
363
+ > **`baseUrl` must be the chat prefix** (`.../api/v1/chat`) and **no bearer is
364
+ > sent** — the stream is authorized by origin allow-list + `?orgId&userId&clientType`.
365
+
366
+ ---
367
+
368
+ ## Hook: `useChatHistory` (separate auth — bearer JWT)
369
+
370
+ ```ts
371
+ import { useChatHistory } from "@elqnt/chat/hooks";
372
+
373
+ const history = useChatHistory({ baseUrl: apiGatewayUrl, orgId }); // gateway ROOT, JWT'd
374
+ ```
375
+
376
+ `useChatHistory(options: UseChatHistoryOptions)` (`= ApiClientOptions`) returns
377
+ `UseChatHistoryReturn`:
378
+
379
+ | Method | Signature | Resolves to | Endpoint |
380
+ |---|---|---|---|
381
+ | `getChatHistory` | `(params?: { limit?: number; offset?: number; skipCache?: boolean }) => Promise<ChatHistoryResult>` | `{ chats: [], total: 0, hasMore: false }` on error | `POST /api/v1/chats` |
382
+ | `getChat` | `(chatKey: string) => Promise<ChatSummary \| null>` | `null` | `GET /api/v1/chats/{chatKey}` |
383
+ | `updateChat` | `(chatKey: string, updates: { title?: string; pinned?: boolean }) => Promise<boolean>` | `true`/`false` | `PATCH /api/v1/chats/{chatKey}` |
384
+ | `deleteChat` | `(chatKey: string) => Promise<boolean>` | `true`/`false` | `DELETE /api/v1/chats/{chatKey}` |
385
+ | `getChatsByUser` | `(userEmail: string) => Promise<ChatSummary[]>` | `[]` | `GET /api/v1/chats/user/{email}` |
386
+
387
+ ```ts
388
+ interface ChatHistoryResult { chats: ChatSummary[]; total: number; hasMore: boolean; }
389
+ interface ChatSummary {
390
+ chatKey: string; title: string; userId?: string;
391
+ status: string; lastUpdated: number;
392
+ waitingSince?: number; pinned?: boolean;
393
+ metadata?: Record<string, any>; isBackground?: boolean;
394
+ }
395
+ ```
396
+
397
+ > **History verbs don't throw.** Like the entity hooks, they're built on
398
+ > `useApiAsync`: each resolves to its default and sets the aggregate `error` on
399
+ > failure. Inspect `history.error` / the returned value — don't wrap in
400
+ > try/catch expecting a throw. `getChat` / `getChatsByUser` return a
401
+ > **`ChatSummary`** (lightweight list shape), not a full `Chat` — load the full
402
+ > thread (with `messages`) via `useChat().loadChat(chatKey)`.
403
+
404
+ ---
405
+
406
+ ## Thread / conversation model (`Chat`)
407
+
408
+ ```ts
409
+ interface Chat {
410
+ orgId: string;
411
+ key: string; // key = chatKey, the thread id
412
+ title: string;
413
+ messages: ChatMessage[];
414
+ startTime: number; lastUpdated: number;
415
+ users: ChatUser[];
416
+ status: 'active'|'disconnected'|'abandoned'|'closed'|'archived'|'completed';
417
+ aiEngaged: boolean; humanAgentEngaged: boolean;
418
+ isWaiting: boolean; isWaitingForAgent: boolean;
419
+ metadata?: Record<string, any>; // holds the agentId/agentName binding
420
+ activeSkillIds?: string[]; deactivatedSkillIds?: string[]; // mid-session skill toggles
421
+ agentContextKey?: string; // "{agentId}:{chatKey}" in NATS KV
422
+ pinned?: boolean;
423
+ chatType?: 'customer_support'|'public_room'|'private_room'|'direct'|'group';
424
+ // …plus grading/flow/context/csat fields
425
+ }
426
+ ```
427
+
428
+ Created via `POST /api/v1/chat/create` → `chatKey` (`useChat().startChat`).
429
+ Referenced by `chatKey` thereafter. The lightweight list shape returned by the
430
+ history hook is `ChatSummary` (above); the full thread with `messages` comes from
431
+ `useChat().loadChat(chatKey)` (or `POST /api/v1/chat/load`).
432
+
433
+ ---
434
+
435
+ ## Minimal end-to-end chat
436
+
437
+ ```tsx
438
+ "use client";
439
+ import { useChat } from "@elqnt/chat/hooks";
440
+ import { getGatewayToken } from "@elqnt/api-client/browser";
441
+ import { useEffect, useState } from "react";
442
+
443
+ export function ChatPanel({ orgId, userId, agentName }: {
444
+ orgId: string; userId: string; agentName: string;
445
+ }) {
446
+ const chat = useChat({
447
+ baseUrl: `${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/api/v1/chat`, // chat prefix, NOT gateway root
448
+ orgId, userId, autoConnect: true,
449
+ getToken: getGatewayToken, // tier-2 token — the service rejects token-less connects
450
+ });
451
+ const [thinking, setThinking] = useState(false);
452
+
453
+ useEffect(() => {
454
+ chat.connect();
455
+ return () => chat.disconnect();
456
+ }, []); // eslint-disable-line
457
+
458
+ useEffect(() => chat.on("agent_execution_started", () => setThinking(true)), [chat.on]);
459
+ useEffect(() => chat.on("agent_execution_ended", () => setThinking(false)), [chat.on]);
460
+
461
+ async function begin() {
462
+ await chat.startChat({ agentName }); // binds thread → a saved agent (see @elqnt/agents)
463
+ }
464
+
465
+ return (
466
+ <div>
467
+ <button onClick={begin} disabled={!chat.isConnected}>Start</button>
468
+ <ul>
469
+ {chat.messages.map((m) => (
470
+ <li key={m.id}><b>{m.role}:</b> {m.content}</li>
471
+ ))}
472
+ </ul>
473
+ {thinking && <p>…thinking</p>}
474
+ <button onClick={() => chat.sendMessage("What's your refund policy?")}>Ask</button>
475
+ </div>
476
+ );
477
+ }
478
+ ```
479
+
480
+ Wrap one `useChat` instance per thread subtree in a context so every component
481
+ shares one `messages` / `connectionState` rather than re-connecting N times —
482
+ the same "one shared hook instance" pattern the entities skill uses for its
483
+ domain context.
484
+
485
+ ---
486
+
487
+ ## Gotchas
488
+
489
+ - **Two auth models — don't cross the wires.** Realtime (`useChat`, transport,
490
+ stream-api) is **tier 2 (direct to the chat service)** → raw `fetch` +
491
+ `EventSource`, authorized by a **signed token from `getToken`** (`?token=` on
492
+ `/stream`, `Authorization: Bearer` on POSTs), `baseUrl` = the chat service
493
+ endpoint (**`.../api/v1/chat`**). History (`useChatHistory`) is **tier 1** →
494
+ `@elqnt/api-client` bearer JWT through the gateway, `baseUrl` = gateway
495
+ **root**. Passing the gateway root to `useChat` (or the chat prefix to
496
+ `useChatHistory`) is the classic failure.
497
+ - **`getToken` is required.** The chat service derives `orgId/userId/product`
498
+ from the token's claims and rejects a token-less connect with **401** (when
499
+ `CHAT_AUTH_ENFORCE=true`, the default). The query `?orgId&userId&clientType`
500
+ are routing hints, **not** identity. Origin is defense-in-depth only — it no
501
+ longer authorizes, and a missing `Origin` is no longer auto-allowed. Scope the
502
+ token to the same org/user the hook runs as. See the handshake section.
503
+ - **No token streaming.** Replies arrive as complete `message` SSE events. No
504
+ `delta`/`token`, no `isStreaming`, no `stop()`. Use
505
+ `agent_execution_started`/`_ended` for a "thinking" UI.
506
+ - **`/send` is async.** It returns `{ success: true }` immediately; the AI reply
507
+ lands later as a `message` event over SSE. Don't read the reply from the POST.
508
+ - **`startChat`/`loadChat` resolve from HTTP.** You don't need to await
509
+ `new_chat_created` / `load_chat_response` SSE events — those are multi-tab
510
+ sync only.
511
+ - **History returns `ChatSummary`, not `Chat`.** `getChat` / `getChatsByUser`
512
+ give the lightweight list shape (no `messages`). Load the full thread with
513
+ `useChat().loadChat(chatKey)`.
514
+ - **History verbs don't throw.** They resolve to defaults and set `error`
515
+ (`useApiAsync`). Check `error` / the result; don't try/catch for throws.
516
+ - **Bind agent → thread** via `startChat({ agentId })` (or `agentName` for
517
+ environment portability — ids differ per environment). The binding lives on
518
+ `Chat.metadata`.
519
+ - **One `useChat` per thread.** Mount it once in a context; don't call it in
520
+ every component (each call opens its own connection + `messages`).
521
+
522
+ ## Related skills
523
+
524
+ - `agents` (`@elqnt/agents`) — **design/save the agent** a thread binds to (LLM
525
+ config, skills, tools, csat/handoff). This skill is chat-only; the agent half
526
+ lives there.
527
+ - `entities` (`@elqnt/entity`) — drive the entities backend the same way (hooks
528
+ → gateway), including the shared "domain layer + one shared hook instance"
529
+ pattern.
530
+ - (Platform) client-functions — the client-side tool round-trip (`client_action`
531
+ SSE event → widget runs a flow → POST a `role:"tool"` result to resume the turn).