omnix-chat
v1.5.6
Published
Embeddable Agent chat SDK for the browser. Configure with a DSN and drop a chat widget into any web page.
Maintainers
Readme
omnix-chat
Embeddable Agent chat SDK for the browser. Configure with a DSN, drop a chat widget into any web page, and let your end users talk to your agent backend.
- 🪶 Headless core —
omnix-chatis a small client; the full UI ships inomnix-chat/reactwith antd 6 + @ant-design/x bundled in (host does not need to install or upgrade antd). - 🔌 React only — install
react+react-domas peers; no antd 6, no @ant-design/x in your app. - 🛡 Host isolation — light-DOM shell with
.ac-embedded-host-scoped static CSS, antd css-in-js confined to.ac-embedded-mount, and overlays in.ac-popup-layer(safe alongside antd 4 or other UI libraries). - 📦 ES2020 default —
omnix-chat/reactis post-built to ES2020; webpack 5 / Vite hosts need noextraBabelIncludes/extraBabelPluginsfor the package. - 🧩 Legacy entry —
omnix-chat/react/legacy(ES2018) for Umi 3 /nodeModulesTransform: 'none'. - 🖥 Panel layouts —
sidebar,floating, orfullscreen; compact session rail with expand/collapse; macOS-style window controls in floating mode. - 📍 Page context — route info always sent; optional extended
pageContextwith preview in the input footer. - ✍️ Write confirmation — destructive writes pause on an inline card on the assistant bubble (no fullscreen modal).
- 🔧 Host tools — register browser-side tools; SSE
host_actionruns them after scope handlers.
Status: public beta. API surface is stable; backend HTTP contract may evolve.
Which entry should I use?
| Host stack | Import | Babel on node_modules |
|------------|--------|-------------------------|
| Vite / webpack 5 (modern) | omnix-chat/react | Not required |
| Umi 3 / old Babel | omnix-chat/react/legacy | Not required |
Both entries bundle the same UI (antd 6 inside the package). Only syntax level differs.
Quick start
中文用法指南:docs/用法指南.zh-CN.md · Umi 改造:docs/umi-integration.zh-CN.md
CDN drop-in (<script> tag)
Only React is required besides the SDK — antd and @ant-design/x are bundled
inside omnix-chat/react.
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/omnix-chat@latest/dist/react.umd.js"></script>
<script>
// Use the named export from the UMD build (see dist types for full API).
const { AgentChat } = OmnixChatReact;
</script>For the headless client (AgentChat.init without pre-built UI):
<script src="https://unpkg.com/omnix-chat@latest/dist/omnix-chat.umd.js"></script>
<script>
OmnixChat.init({ dsn: 'your-app-dsn', baseUrl: 'https://api.example.com' });
</script>npm + React(推荐,开箱即用)
无需在宿主项目安装 antd 6 或 @ant-design/x — 它们已内置在 omnix-chat/react 中。
pnpm add omnix-chat react react-domimport { AgentChat } from 'omnix-chat/react';
export function App() {
return (
<AgentChat
dsn="your-app-dsn"
baseUrl="https://api.example.com"
accountToken="optional-host-token"
project={{ name: 'My Assistant', logo: '/logo.png' }}
locale="zh-CN"
/>
);
}Umi 3 / webpack 5(推荐 legacy 入口)
Umi 3 请使用 omnix-chat/react/legacy,并关闭对 node_modules 的 Babel 转译(否则
会把包内预编译产物再跑一遍 Babel,出现 @babel/runtime/regenerator 等解析错误):
pnpm add omnix-chat react react-domimport { AgentChat } from 'omnix-chat/react/legacy';// config/config.ts 或 .umirc.ts
export default {
nodeModulesTransform: {
type: 'none',
},
// 若仍走 MFSU 预构建,可排除 omnix-chat:
// mfsu: { exclude: ['omnix-chat'] },
};react / react-dom 为必装 peer。包内 不发布 dist/recharts-*.js 等
async chunk,避免 webpack 在 node_modules/omnix-chat/dist 下解析失败。
npm + vanilla JavaScript(无 UI)
pnpm add omnix-chatimport { AgentChat } from 'omnix-chat';
AgentChat.init({
dsn: 'your-app-dsn',
baseUrl: 'https://api.example.com',
locale: 'zh-CN',
});React(自定义布局)
import { AgentChatProvider, AgentChatEmbedded, useAgentChat } from 'omnix-chat/react';
export function App() {
return (
<AgentChatProvider dsn="your-app-dsn" baseUrl="https://api.example.com">
<YourApp />
<ChatShell />
</AgentChatProvider>
);
}
function ChatShell() {
const { instance, isOpen, open, close } = useAgentChat();
return (
<>
<button type="button" onClick={isOpen ? close : open}>
{isOpen ? 'Hide chat' : 'Need help?'}
</button>
<AgentChatEmbedded client={instance} />
</>
);
}DSN format
A DSN is a URL-shaped string that encodes everything the SDK needs to talk to your backend:
https://<publicKey>@<host>[:<port>][/<basePath>]/<projectId>publicKey— the project's public key. Visible on the client; never use it to sign sensitive operations.host/port/basePath— origin and base path of your agent API. The SDK computesapiBase = ${host}:${port}${basePath}/v1.projectId— string or numeric project identifier.
Examples:
https://[email protected]/42
https://[email protected]:8443/api/edge/proj-7
agent://[email protected]/9 # alias, normalized to https://Parsing / normalization:
import { normalizeDsn, DEFAULT_BASE_URL } from 'omnix-chat';
const dsn = normalizeDsn('your-app-dsn');
await AgentChat.init({ dsn, baseUrl: DEFAULT_BASE_URL });Configuration reference
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| dsn | string | — | Required. See DSN format. |
| user | UserContext | — | End-user identity. Forwarded to the backend at handshake time. |
| locale | string | navigator.language | BCP-47 locale (en, zh-CN, …). Drives both SDK and antd copy. |
| theme | 'light' \| 'dark' \| 'auto' | 'auto' | Visual theme. auto follows prefers-color-scheme. |
| themeToken | Record<string, unknown> | — | antd theme token overrides (e.g. { colorPrimary: '#7c3aed' }). |
| position | 'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' | 'bottom-right' | Where the floating launcher anchors. |
| container | HTMLElement \| string | document.body | Where the widget host (.ac-embedded-host) is appended. |
| autoOpen | boolean | false | Open the panel as soon as the widget mounts. |
| baseUrl | string | — | Agent API origin when not fully encoded in the DSN. |
| accountToken | string | — | Optional host auth token forwarded on API calls. |
| project | { name, logo?, … } | — | Branding shown in the panel header. |
| panelMode | 'sidebar' \| 'floating' \| 'fullscreen' | 'sidebar' | Initial panel layout. |
| panelModeSwitchable | boolean | true | Let users switch layout from the header menu. |
| pageContext | AgentChatPageContext | — | Extended business context (entity, metadata, …). |
| attachPageContext | boolean | false | Default for the input-footer checkbox that merges pageContext into the next send. Route info is always sent. |
| debug | boolean | false | Verbose console logs and unhandled-error warnings. |
| headers | Record<string, string> | — | Extra HTTP headers added to every backend call. |
| onReady | () => void | — | Convenience alias for instance.on('ready', ...). |
Events
const off = AgentChat.on('message', ({ message, source }) => {
console.log(source, message.content.text);
});
off(); // unsubscribe| Event | Payload | Notes |
| --- | --- | --- |
| ready | void | Fired after init() settles. |
| error | { code, message, cause? } | Any SDK-level error. Subscribe in production to forward into Sentry/Datadog. |
| open / close | void | The chat panel opened/closed. |
| session | { sessionId, conversationId } | Active chat session changed. |
| sessions | { sessions: ChatSessionSummary[] } | Sidebar session list refreshed (GET /chat). |
| message | { message, source } | A user, agent, or system message hit the timeline (agent may stream incrementally). |
| hostAction | { action } | Agent requests a host-page side effect (refresh data, navigate, …). |
| destroyed | void | The instance was torn down. |
Host page actions
See docs/host-page-context-and-actions.md for the full
spec: pageContext request format, SSE host_action envelope, field alignment, and integration
examples.
When the agent needs the host page to run browser-side tools, the backend emits SSE
host_action with a hostTools list (instead of legacy type: refresh).
Backend envelope (SSE event host_action, or message with action: host_action):
{
"action": "host_action",
"scope": "campaign-detail",
"entity": { "type": "campaign", "id": "123" },
"hostTools": [
{ "name": "refreshEntity", "args": { "entityType": "campaign", "entityId": "123" } }
],
"reason": "agent_mutation_success",
"runId": 42,
"turnId": 7
}scopemust matchpageContext.pageon the user message.hostToolsis required (server only pushes when non-empty).reason:plan_host_tool(mid-run Plan step) oragent_mutation_success(after HTTP write).
React page component — register while mounted:
import { useAgentChat, useHostAction, HOST_ACTION_REASON } from 'omnix-chat/react';
function CampaignDetailPage({ campaignId }: { campaignId: string }) {
const { instance } = useAgentChat();
const { refetch } = useCampaignDetail(campaignId);
useEffect(() => {
instance.setPageContext({
page: 'campaign-detail',
entity: { type: 'campaign', id: campaignId },
});
}, [campaignId, instance]);
useHostAction('campaign-detail', async (action) => {
if (action.entity?.id && action.entity.id !== campaignId) return;
if (action.reason === HOST_ACTION_REASON.AGENT_MUTATION_SUCCESS) {
await refetch();
}
});
return <CampaignView />;
}hostTools are executed automatically by the SDK; use useHostAction for guards and
extra logic keyed on reason.
Imperative / non-React pages:
import { registerHostAction, HOST_ACTION_REASON } from 'omnix-chat';
const off = registerHostAction('campaign-detail', (action) => {
if (action.entity?.id && action.entity.id !== campaignId) return;
if (action.reason === HOST_ACTION_REASON.AGENT_MUTATION_SUCCESS) {
loadCampaignData();
}
});
// later: off();The SDK also emits instance.on('hostAction', ({ action }) => …) if you prefer
listening on the client instead of the registry.
Host tool registration
Register named tools the agent can invoke via hostTools in SSE host_action:
import {
registerHostTool,
registerHostToolsWithSync,
type HostToolDefinition,
} from 'omnix-chat/react';
registerHostTool('refreshEntity', async (args) => {
await refetchCampaign(args.entityId as string);
return { ok: true };
});
// Optional: sync tool metadata to the backend catalog
await registerHostToolsWithSync(instance, [
{
name: 'refreshEntity',
description: 'Refetch a campaign entity after agent writes',
parameters: { type: 'object', properties: { entityId: { type: 'string' } } },
} satisfies HostToolDefinition,
]);registerHostToolsWithSync calls POST /host-tool/client/register so the agent
knows which tools exist on this page. runHostTool is available for imperative
calls; the SDK auto-runs matching hostTools entries after registerHostAction
handlers when an SSE host_action arrives.
Migration (v1.5.0):
host_actionno longer requiresstatus: 'completed'. Payloads must include non-emptyhostToolsand ascopematchingpageContext.page. See CHANGELOG.
Page context
Route information (routePath, routeParams) is always attached to user
messages. Extended fields (page, entity, metadata, …) are sent only when the
user enables attach page context (footer checkbox) or you set
attachPageContext: true on init.
<AgentChat
pageContext={{ page: 'campaign-detail', entity: { type: 'campaign', id } }}
attachPageContext={false}
/>instance.setPageContext({ page: 'campaign-detail', entity: { type: 'campaign', id } });
instance.setAttachPageContext(true); // or toggle via UI checkboxThe input footer shows a preview icon: inspect route + extended fields and the JSON that will be sent on the next message. Full spec: docs/host-page-context-and-actions.md.
Write confirmation
When the agent proposes a destructive write, the backend emits
confirmation_required on the SSE stream. The SDK shows an inline card on
the assistant message (not a modal) with confirm / cancel actions.
// Programmatic (same as clicking the inline buttons)
await instance.confirmWrite();
await instance.cancelWrite();Listen for UI state via getState().writeConfirmation or subscribe to state
changes after confirmation_required / complete events.
Panel layout
| Mode | Behaviour |
| --- | --- |
| sidebar | Docked panel with session rail on the left (default). |
| floating | Draggable window with macOS-style close / minimize / fullscreen controls. |
| fullscreen | Occupies the host viewport; session rail collapses to a slim icon strip. |
In sidebar and floating, the session rail is 116px when expanded (titles
visible) and 40px when collapsed. Use panelModeSwitchable: false to lock the
layout, or instance.setPanelMode('floating') imperatively.
Lifecycle
AgentChat.init({ dsn });
AgentChat.open();
AgentChat.identify({ id: 'u-99' });
// Resolves with the **user** message once POST succeeds.
// Assistant replies arrive via `message` events (SSE stream per sessionId).
const userMsg = await AgentChat.sendMessage('Hello!');
AgentChat.selectSession('existing-session-id'); // switch sidebar session
AgentChat.destroy(); // clean up DOM, abort in-flight requests
AgentChat.init({ dsn }); // safe to re-init after destroyFor multi-tenant pages use createAgentChat() to obtain independent instances.
React Strict Mode
If you call the imperative AgentChat.init() from inside a React useEffect
under <StrictMode>, dev-mode will run setup → cleanup → setup, which
otherwise causes the launcher to flash on screen. Prefer <AgentChat dsn="..." />
from omnix-chat/react, which handles init/teardown for you. If you still call
the imperative API from an effect, guard with a deferred destroy:
const ref = useRef<{ key: string | null; timer: number | null }>({ key: null, timer: null });
useEffect(() => {
if (ref.current.timer != null) {
clearTimeout(ref.current.timer);
ref.current.timer = null;
}
if (ref.current.key !== dsn) {
if (ref.current.key) AgentChat.destroy();
AgentChat.init({ dsn });
ref.current.key = dsn;
}
return () => {
ref.current.timer = window.setTimeout(() => {
AgentChat.destroy();
ref.current.key = null;
ref.current.timer = null;
}, 0);
};
}, [dsn]);Multi-session chat & streaming
The widget shows a left sidebar of conversations (GET /chat). Selecting one
loads GET /chat/{sessionId} into the message pane.
sendMessage() pipeline (SDK)
User hits Send
→ optimistic user bubble (pending) + notifyStateChange ← UI shows immediately
→ ensure sessionId
→ GET /chat/{sessionId}/stream (SSE connected)
→ POST user message (existing session only)
→ SSE events → agent bubble updates + notifyStateChange ← UI streams assistantExisting session (has activeSessionId):
| Order | HTTP | Purpose |
| --- | --- | --- |
| 1 | GET /chat/{sessionId}/stream | Listen before agent runs |
| 2 | POST /chat/{sessionId}/messages | { role: "user", content } triggers agent |
| 3 | same SSE | think / result / complete / error |
First message (no session yet):
| Order | HTTP | Purpose |
| --- | --- | --- |
| 1 | POST /chat | { role, content } → { sessionId } + starts agent (backend contract) |
| 2 | GET /chat/{sessionId}/stream | Subscribe (ReplaySubject replays recent events) |
| — | skip POST .../messages | User text already persisted in step 1 |
Other APIs
| Step | HTTP | Notes |
| --- | --- | --- |
| List sessions | GET /chat | Sidebar |
| Load session | GET /chat/{sessionId} | selectSession() / prefetch() |
SSE → UI mapping lives in src/core/chat-sse.ts. React reads getState().messages
via useClientState in ChatBody (useSyncExternalStore + notifyStateChange).
sendMessage() resolves when the user bubble is sent or failed. Assistant
content streams through repeated message events and state updates.
Backend HTTP contract (legacy v1 sketch)
The SDK historically documented two endpoints:
POST {apiBase}/sessions
Headers: X-Agent-Public-Key, X-Agent-Project-Id, X-Agent-Sdk-Version,
Content-Type: application/json.
Body:
{
"locale": "zh-CN",
"sdkVersion": "1.5.0",
"origin": "https://app.example.com",
"user": { "id": "u-123", "email": "[email protected]" },
"existingConversationId": "conv_abc"
}Response (200):
{
"sessionId": "sess_...",
"sessionToken": "tok_...",
"expiresAt": 1731567890000,
"conversation": { "id": "conv_...", "messages": [] }
}POST {apiBase}/messages
Headers: Authorization: Bearer <sessionToken>, plus the same
X-Agent-* headers.
Body:
{
"conversationId": "conv_abc",
"content": { "type": "text", "text": "Hello!" }
}Response (200):
{
"message": {
"id": "msg_...",
"role": "agent",
"content": { "type": "text", "text": "Hi there!" },
"createdAt": 1731567890000,
"status": "sent"
}
}Errors should follow the shape { "error": { "code": "STRING_CODE", "message": "Human readable" } }
so the SDK can surface them as typed AgentChatError instances.
Security
- The DSN's
publicKeyis public. Any rate-limiting or origin allow-list must be enforced server-side (the SDK forwardsOriginin the handshake body so you can check it). - For trusted user identification pass an HMAC of the user id as
user.signature(validate it on the backend with a shared secret). - The SDK never executes
evalornew Function. Static widget CSS is injected once under#agent-chat-embedded-styleswith every selector prefixed by.ac-embedded-host; antd css-in-js stays inside.ac-embedded-mount. CSP typically needsstyle-src 'unsafe-inline'for css-in-js.
Local development
pnpm install
pnpm dev # runs the demo playground at http://localhost:5173
pnpm build # produces dist/omnix-chat.es.js + .umd.js + react.es.js + .umd.js + types
pnpm build:watch # rebuild dist/ on src/ changes (use while link-debugging in another app)
pnpm pack:local # build + pack/omnix-chat-x.y.z.tgz for offline install
pnpm typecheck
pnpm size # asserts gzipped budgetsLink into your local app (pnpm)
# SDK repo — build once (or run build:watch in another terminal)
cd /path/to/agent-chat
pnpm install && pnpm build
# Your app — symlink the package
cd /path/to/your-app
pnpm add "link:/path/to/agent-chat"In your app:
import { AgentChat } from 'omnix-chat/react';While developing the SDK, keep pnpm build:watch running in the agent-chat repo;
your app picks up changes after each rebuild (Vite may need a refresh).
Tarball instead of symlink: pnpm pack:local then
pnpm add /path/to/agent-chat/pack/omnix-chat-*.tgz.
See examples/react/README.md for a minimal consumer app.
The playground intercepts /api/edge/*/v1/sessions and
/api/edge/*/v1/messages via a built-in Vite middleware, so you can develop
without a real backend.
License
MIT
