This article is auto-synced from its in-app version in Tai.
Not yet generally available. The TAI Chat SDK currently powers Nezasa's own TripBuilder integration and isn't open for general use yet. This reference is published for Nezasa and TripBuilder developers; the public surface may still change before general availability.
The TAI Chat SDK is a reusable, embeddable chat experience backed by TAI's Streaming API — the same chat the Admin Console's TAI Terminal uses (SSE streaming, session management, thinking / tool-step display, markdown and mermaid / draw.io rendering, file upload, export). It ships in two delivery forms from one codebase:
Package | For | Tag / import |
| React 18 / 19 hosts |
|
| Any other host (Ember, Vue, AngularJS, plain HTML) |
|
The Web Component wraps the React package in native custom elements (React, ReactDOM, and TanStack Query are bundled in), so the two share behaviour, config, theming, and localization. This page documents both: shared concepts once, then a per-form reference for the parts that differ.
Related documents:
Agents — how to provision the agents you reference from
agentAgent access — controlling which users can run each agent
Your host app supplies the bearer token the SDK sends; the SDK never issues or stores credentials. The SDK talks to TAI's Streaming API; you don't call it directly when embedding.
When to use this SDK
React 18 / 19 host → use
@nezasa/tai-chat. Typed props / refs / hooks, ~70 KB smaller (React is a peer dep, not bundled).Any non-React host (Ember, Vue, AngularJS, Svelte, vanilla JS, a static marketing page) → use
@nezasa/tai-chat-element. One<script type="module">and plain HTML tags; no framework needed.Full control over rendering, or a non-browser runtime → talk to TAI's Streaming API directly.
Installation & authorization
Both packages publish to GitHub Packages under the @nezasa scope. Point the scope at the GitHub registry in your project's .npmrc (or ~/.npmrc):
@nezasa:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
Then install the form you need:
npm install @nezasa/tai-chat # React package npm install @nezasa/tai-chat-element # Web Component
React package | Web Component | |
Peer deps |
| none — React, ReactDOM, TanStack Query are bundled |
Stylesheet |
| adopted automatically into each element's shadow root |
React Query is a peer dep on purpose: most hosts already use it, so a shared QueryClient avoids duplicate caches. Axios, marked, DOMPurify, highlight.js, mermaid, vega / vega-lite (polished data charts), and Lucide are bundled — you don't install them.
GitHub PAT + SSO authorization
npm install 401s on @nezasa packages until you create a Personal Access Token that is (a) scoped to read packages and (b) SSO-authorized for the nezasa org. The token alone works on personal projects but fails on org-scoped packages with a 401 that looks like the package doesn't exist.
Create a classic PAT. GitHub Packages for
@nezasaneeds a classic token (fine-grained tokens don't support the Packages API yet). At https://github.com/settings/tokens → Generate new token (classic), checkread:packagesonly (notwrite:packages/repo— a broader token is a bigger blast radius), and copy theghp_…value.SSO-authorize it. On the token's page, click Configure SSO → Authorize next to
nezasaand complete the SSO redirect. Without this the install fails with a misleading404-like401.Store it securely.
export NPM_TOKEN=ghp_…in your shell profile so the.npmrc's${NPM_TOKEN}resolves and the raw token never touches a committed file. Don't share a PAT between engineers or reuse a local PAT for CI (runners use a service-account PAT orsecrets.GITHUB_TOKEN).Verify before a full install:
NPM_TOKEN=ghp_… npm view @nezasa/tai-chat@latest version
A version number means auth works;
401means the token is missing, expired, lacksread:packages, or isn't SSO-authorized.
Bundle size
The React package's main entry is ~115 KB gzipped; mermaid, vega / vega-lite (the heaviest — ~270 KB gzipped, fetched only when a message renders a polished chart), highlight.js, and @tanstack/react-table are lazy-loaded on first use, so plain-text conversations don't pay for them. CI enforces a 150 KB gzipped budget on the main entry. The Web Component bundle is larger because React + ReactDOM + TanStack Query ship inside; its gzipped ceiling is also CI-enforced, and the same deps stay lazy. Lazy-load the element module (await import("@nezasa/tai-chat-element")) if you want it off the critical path.
Polished charts render without unsafe-eval: the Vega runtime is evaluated through an AST interpreter, not the Function constructor, so a strict host Content-Security-Policy (script-src with no unsafe-eval) doesn't need loosening for charts to work.
Release channels
Both packages publish on three dist-tags:
Dist-tag | Points at | Install |
| Tagged releases ( |
|
| Immutable snapshot of every commit on |
|
| Snapshot of an in-progress change; the tag floats to its latest push |
|
latest— production. Pin a caret range ("^1.0.0") and updates stay safe.dev— cross-repo integration work that needs an unreleased change. Snapshots are immutable; pin an exact snapshot for a deterministic build.pr-<N>— verify a specific change's exact build downstream before it ships. The tag is meaningless once that change lands (use@dev, then@latest).
npm v7+ note: snapshot versions are semver prereleases, so they don't satisfy plain caret ranges — install by exact version or dist-tag, and don't commit the lockfile change downstream unless you intend to ship that snapshot.
Every snapshot runs the full build-and-verify pipeline (bundle-size budget, API-surface snapshot, test suite, a pack-install-and-mount smoke test) before publish, so regressions are caught before they reach a dist-tag.
Versioning policy
The SDK follows semver. Bump levels:
major — removing/renaming a prop, attribute, exported component, or hook; changing a prop type; removing a CSS custom property; narrowing the peer-dep range; a behaviour change consumers must adapt to.
minor — a new optional prop/attribute, exported component, hook, CSS custom property, or purely additive default.
patch — bug fixes, internal refactors, doc-only changes.
Quick start
React package
A provider plus the chat component:
import { TaiChat, TaiChatProvider } from "@nezasa/tai-chat";
import "@nezasa/tai-chat/style.css";function App() {
const token = useMyAuth(); // whatever your app uses for bearer tokens return (
<TaiChatProvider
config={{
apiBaseUrl: "https://tai.nezasa.com",
authToken: token,
onAuthError: () => refreshToken(),
}}
>
<TaiChat showSessions showExport showThinking />
</TaiChatProvider>
);
}
The SDK is stateless about auth and routing — your app owns the bearer token (the SDK reads config.authToken on every request; pass a fresh value when you rotate), handles onAuthError (the SDK calls it on a 401 — refresh, redirect, or show an error), and mounts the provider at the right level. Before mounting: create an agent (Agents), ensure the principal behind the token can run that agent (Agent access), and configure CORS if you embed cross-origin.
Web Component
The custom elements (<tai-chat>, <tai-chat-drawer>, <tai-chat-widget>) self-register the moment the bundle is parsed. The only host-specific question is how the bundle reaches the browser.
Bundler hosts (Ember ≥ 3.13, Vue, Webpack, Vite, …) — import the package once from your app entry; the elements self-register and the tags work in every template:
import "@nezasa/tai-chat-element"; // self-registers the three elements
<tai-chat
api-base-url={{this.tai.baseUrl}}
auth-token={{this.tai.token}}
agent="your-agent-id"
show-sessions
/>
For Vue 3, tell the compiler the tag is a custom element so it doesn't strip attributes: isCustomElement: (tag) => tag.startsWith("tai-chat") in the @vitejs/plugin-vue options.
Raw HTML / AngularJS / no bundler — npm install, copy the single bundled ESM file into your public assets, and reference it with a module script (most production servers don't expose node_modules/ to the browser):
cp node_modules/@nezasa/tai-chat-element/dist/tai-chat-element.js public/assets/
<script type="module" src="/assets/tai-chat-element.js"></script> <tai-chat api-base-url="https://tai.nezasa.com" auth-token="<jwt>" agent="your-agent-id" show-sessions ></tai-chat>
Legacy Ember 2.18. Auto-import 1.x can't resolve modern ESM packages. Skip it: pull dist/tai-chat-element.js into assets/ with a broccoli-funnel in ember-cli-build.js, reference it as a <script type="module"> in app/index.html, and use the tag in any .hbs (the dash tells the template compiler it's a custom element). Set function callbacks and array-valued agent as JS properties in a didInsertElement hook — see JS properties.
Embedding modes
Three surfaces, same conversation engine:
Mode | React component | Element tag | Typical use |
Inline |
|
| Full-page chat, embedded panel |
Drawer |
|
| Slide-in side panel (Jira-style) |
Widget |
|
| Floating FAB + popup (Intercom-style) |
The React modes render inside your app's React tree (portal modes included), so context, theme, and the React Query cache work transparently — no iframe. The element modes render inside an open shadow root per instance.
Live showroom: Inline, Drawer, and Widget rendered side-by-side, with a prop-toggle panel and copy-ready snippets, at Docs → SDKs → Showroom in the Workbench.
Inline fills its parent's height — give it an ancestor with an explicit height (h-screen, h-[600px], a flex parent with flex-1).
<TaiChat showSessions showExport showThinking showLayoutSwitch />
Drawer is host-controlled open/closed:
<TaiChatDrawer open={open} onClose={() => setOpen(false)} width="420px" agent="support-agent" />
Widget is a self-managed FAB + popup:
<TaiChatWidget position="bottom-right" label="Ask TAI" agent="support-agent" />
Configuration reference
The two forms expose the same options under their native idioms: React props / provider config, or HTML attributes / JS properties. r2wc maps a camelCase prop to a kebab-case attribute automatically (apiBaseUrl → api-base-url).
React package: props & provider config
<TaiChatProvider> — supplies config, theme, and an internal React Query client; must wrap every <TaiChat* />.
Prop | Type | Default | Purpose |
|
| required | API base URL + auth (below) |
|
|
| Theme overrides (see Theming) |
|
| internal | Optional: reuse your app's client |
interface TaiChatConfig {
/** Base URL of the TAI API (e.g., "https://tai.nezasa.com") */
apiBaseUrl: string;
/** Bearer token — consumer manages lifecycle */
authToken: string;
/** Called on 401 — consumer refreshes token or redirects to login */
onAuthError?: () => void;
}
<TaiChat> (inline):
Prop | Type | Default | Purpose |
|
|
| Fixed agent (string), allow-list (array), or all agents (undefined) |
|
| varies by surface | How the sessions list is surfaced (see Sessions layouts) |
|
| — | Deprecated alias; |
|
|
| Export dropdown (Markdown / ZIP / PDF) |
|
|
| Adds the "Include technical details" export option (Nezasa-internal; the server rejects the technical payload for non-Nezasa principals regardless) |
|
|
| Adds a "Diagnostics" section with a per-session trace opt-in + full-trace (zip) download (Nezasa-internal; the server rejects trace requests for non-Nezasa principals regardless) |
|
|
| Thinking and tool-step blocks |
|
|
| Chat-column width toggle (only while a conversation is active) |
|
|
| Per-agent star toggles + a "Starred" filter (requires a user identity) |
|
|
| Harness selector (see Harness selection) |
|
| — | Pre-select the harness for new sessions |
|
|
| Side of the textarea the send / stop button sits on (see Send-button position) |
|
| — | Claim a link click in an agent answer and route it yourself (see Routing links in place) |
|
| — | Extra class on the root container |
agent has three modes: fixed (agent="id" — no selector, opens against that agent), allow-list (agent={["a","b"]} — selector limited to those), all (omit — selector lists every accessible agent). A fixed or allow-list id is the agent's UUID, shown as a copyable value on the agent's page in the Workbench. Built-in agents also have a Reference — a stable nezasa/… name you can use in place of the UUID.
<TaiChatDrawer> extends all <TaiChat> props, adds:
Prop | Type | Default | Purpose |
|
| required | Visible or not |
|
| required | Backdrop click or Escape |
|
|
| Drawer width |
|
|
| Translucent backdrop |
|
| — | Show the "expand to full page" handoff icon (see Surface handoff) |
Defaults sessionsLayout to "accordion".
<TaiChatWidget> extends all <TaiChat> props, adds:
Prop | Type | Default | Purpose |
|
|
| FAB corner |
|
|
| FAB label |
|
|
| Popup width |
|
|
| Popup height |
|
|
| Distance from the bottom edge (see Clearing a fixed corner element) |
|
| — | Show the "expand to drawer / full page" handoff icon |
Defaults sessionsLayout to "hidden".
Web Component: attributes & JS properties
All elements take the shared config + display attributes; drawer and widget add their own. Boolean attributes follow HTML convention — presence is truthy (show-sessions or show-sessions=""), absence is falsy.
Attribute | Type | Required | Purpose |
| string | yes | TAI backend URL |
| string | yes | JWT for TAI's auth middleware |
| string | no | Fixed agent id. For allow-list mode set |
| string | no |
|
| boolean | no | Deprecated alias — |
| boolean | no | Export dropdown |
| boolean | no | Adds the "Include technical details" export option (Nezasa-internal; server-gated) |
| boolean | no | Adds the "Diagnostics" trace section (Nezasa-internal; server-gated) |
| boolean | no | Thinking / tool-step blocks |
| boolean | no | Layout menu (chat width + message alignment) |
| string | no | Pre-select |
| boolean | no | Harness selector dropdown |
| string | no | Markdown greeting; overrides the agent's |
| string | no | Resume a session (controlled; see Session continuity) |
| string | no | Seed the initial session once at mount (uncontrolled) |
| string | no |
|
| string | no | Extra CSS class on the React root (distinct from native |
<tai-chat-drawer> adds: open (boolean), width (string), show-backdrop (boolean). <tai-chat-widget> adds: position, label, width, height, offset-bottom (all strings).
JS properties (HTML attributes are strings, so callbacks, arrays, and objects are set on the element instance):
const el = document.querySelector("tai-chat");
el.onAuthError = () => window.location.assign("/login");
el.agent = ["agent-a", "agent-b"]; // allow-list mode
el.context = { description: "…", data: {} }; // page context (object-only)
document.querySelector("tai-chat-drawer").onClose = () => drawerClosed();
r2wc re-renders the inner React tree whenever a property or attribute changes, so every property is reactive. Property-only props: onAuthError, onClose, onSessionChange, onAgentChange, onRequestExpand, onRequestCollapse, onLinkClick, agent (array form), context, initSuggestions, strings. @nezasa/tai-chat-element augments HTMLElementTagNameMap, so document.querySelector("tai-chat-drawer") returns a typed element with autocomplete on these properties.
Sessions layouts
Three layouts trade real-estate for discoverability:
"sidebar"(inline default) — full-height left panel, always visible. Best with ≥600px of horizontal room."accordion"(drawer default) — collapsible "Recent sessions" bar between the agent strip and chat body; ~36px collapsed, caps at 320px expanded and scrolls internally so the composer stays visible."hidden"(widget default) — no sessions UI, and the sessions API call is skipped.
Legacy showSessions={true} / show-sessions maps to "sidebar"; false maps to "hidden".
Send-button position
sendButtonPosition (send-button-position) controls which side of the message textarea the send (and stop, while streaming) button sits on:
"right"(default) —[attachment] [textarea] [send], the standard layout."left"—[attachment] [send] [textarea], which keeps the bottom-right corner clear for another fixed element such as a support launcher.
<TaiChat agent="support-agent" sendButtonPosition="left" />
<tai-chat agent="support-agent" send-button-position="left"></tai-chat>
Clearing a fixed corner element
The widget FAB sits 24px from the bottom-right corner by default, which can collide with another fixed corner element (a support launcher, a cookie banner). Raise offsetBottom (offset-bottom) — any CSS length — to stack the FAB and its popup above it:
<TaiChatWidget position="bottom-right" offsetBottom="5.5rem" />
<tai-chat-widget position="bottom-right" offset-bottom="5.5rem"></tai-chat-widget>
Sessions, handoff & page context
Session continuity
Every surface accepts sessionId / session-id plus an onSessionChange callback. Together they let a host move a running conversation between surfaces — typically a drawer in your app shell and a full-page route — without re-creating the session, refetching messages, or losing the agent's context. The host owns the value; the SDK never decides where the conversation lives.
onSessionChange fires on SDK-driven transitions: auto-create, the user picking a session, or deletion of the active session (with null). It does not fire when the host writes session-id itself. If session-id references a session whose agent doesn't match agent, the SDK surfaces a visible error rather than swapping agents silently — omit agent to let the SDK derive it from the session.
// Drawer in the app shell + a full-page route, both reading one shared id. const handleSessionChange = (id) => sessionStore.set(id); drawer.onSessionChange = handleSessionChange; fullPage.onSessionChange = handleSessionChange; fullPage.sessionId = sessionStore.get(); // resume in either surface
Seed once vs. control. sessionId / session-id is controlled — the host owns the id for the surface's lifetime, so in-SDK switching only sticks if the host writes the new id back. To resume one conversation at mount and then let the surface own switching, use defaultSessionId / default-session-id: read once at mount, ignored while the controlled id is set, never echoed through onSessionChange. In free-agent mode it loads history but leaves the agent header blank; pass agent when you also need the agent strip to resolve.
Deep-linking agent + session
onAgentChange (onAgentChange?: (agentId: string | null) => void) mirrors onSessionChange for the active agent — it fires when the user picks an agent from the picker / switcher (the agent's UUID) or clears back to the picker (null). Combine the two to keep ?agent= and ?session= in the URL so the surface survives a refresh. Clear ?session= whenever the agent changes — session IDs are agent-scoped, so a stale one triggers a mismatch error on reload.
React — drive both from your router state:
<TaiChat
agent={initialAgent} // captured once at mount (see below)
defaultSessionId={initialSession}
onAgentChange={(id) => setParams({ agent: id ?? undefined, session: undefined })}
onSessionChange={(id) => setParams((p) => ({ ...p, session: id ?? undefined }))}
/>
Web Component — set the callbacks as JS properties:
const el = document.querySelector("tai-chat");
el.onAgentChange = (agentId) => {
const url = new URL(location.href);
agentId ? url.searchParams.set("agent", agentId) : url.searchParams.delete("agent");
url.searchParams.delete("session"); // agent-scoped — drop the stale id
history.replaceState(null, "", url);
};
el.onSessionChange = (sessionId) => {
const url = new URL(location.href);
sessionId ? url.searchParams.set("session", sessionId) : url.searchParams.delete("session");
history.replaceState(null, "", url);
};
On load, read the params and pass them in once — set agent only at mount:
const params = new URLSearchParams(location.search);
if (params.get("agent")) el.setAttribute("agent", params.get("agent"));
if (params.get("session")) el.setAttribute("default-session-id", params.get("session"));
Capture agent once at mount. Don't re-assign agent when onAgentChange later writes ?agent= to the URL — passing a string agent back in switches the surface into fixed-agent mode and hides the picker / switcher for the rest of the session. Read it from the URL at mount only.
Surface handoff
The SDK draws a "switch surface" icon inside the chat chrome; the host opts in by setting a callback and owns where to navigate (the SDK doesn't unmount, close a drawer, or touch routing). The icon shows only when its callback is set.
Surface | Callback | Meaning |
Drawer |
| Send the conversation to a full-page surface |
Widget |
| Send the conversation to a drawer or full page |
Inline |
| Send the conversation back to a side surface |
onRequestCollapse exists only on the inline surface — it makes no sense from a surface that's already a side panel. Pair handoff with sessionId so the target surface resumes the same chat (history and any in-flight stream both carry over — streams are shared per session across all surfaces under one provider).
Routing links in place
Links in an agent answer open in a new tab. That's the right default for a chat the user must not lose — following a link in place would navigate the host page away and take the conversation with it.
It's the wrong default in one case: a chat embedded in the very app its links point at. A Customer Care drawer sitting inside cockpit, answering with cockpit deep links, opens a second copy of an app the user is already running — and triaging three bookings leaves three tabs behind.
onLinkClick lets you claim the click instead. Return true and the SDK suppresses its own navigation; return anything else (or nothing) and the link behaves normally.
<TaiChat
agent="…"
onLinkClick={(href) => {
const url = new URL(href);
if (url.origin !== window.location.origin) return false; // not ours — new tab
navigate(url.pathname + url.search + url.hash);
return true;
}}
/>
// Web Component: a function can't be an HTML attribute, so set the property.
document.querySelector("tai-chat-drawer").onLinkClick = (href) => { … };
Worth knowing:
hrefis absolute, resolved against the page — so an origin comparison is always meaningful.Modified clicks never reach the callback. Middle-click and ⌘/Ctrl/Shift/Alt-click are the user explicitly asking for a new tab or window; the SDK leaves those to the browser.
Every link the agent authored is offered, including links inside tables (expanded or not) and diagrams. The SDK's own chrome — export menu, file downloads — is never routed through it. One exception: a link whose href is a bare relative path (
bookings/123) is not rendered as a link at all, so it never reaches the callback. Write deep links as absolute URLs.Claiming the click is the whole contract. The SDK doesn't navigate, close a drawer, or touch routing; getting the user there is yours.
Page context
Tell the agent what the user is looking at. Pass a ChatContext envelope — via the context prop (React) or the context JS property (element) — and the SDK forwards it to the backend on session creation; the agent receives it as a ## Page context block in its system prompt.
interface ChatContext {
/** Free-form host-defined payload. Any JSON-serialisable value. */
data?: unknown;
/** Plain-language description of what `data` represents. */
description?: string;
}
drawer.context = {
description: "User is viewing itinerary IT-12345 for the Smith family.",
data: { itineraryRefId: "IT-12345", url: location.href },
};
description is the one-line prose framing an LLM benefits from — only the host can write it well. context is object-only (nested objects don't round-trip through HTML attributes). Locked at session creation: changing it on a live session has no effect — start a new session (clear session-id) to apply new context.
Starter prompts
Override the selected agent's greeting and starter chips, or leave them unset to use the agent's defaults:
React prop | Element | Type | Purpose |
|
| string | Markdown greeting on a fresh chat |
|
|
| Up to 10 starter prompts; clicking one sends it as the first user message |
initSuggestions is property-only because arrays can't survive HTML attribute serialisation. Clicking a chip uses the same handler as Enter-to-send.
Harness selection
The execution engine (harness) is chosen per session. Use showHarnessSwitch / show-harness-switch to let the user pick from the agent's supported engines, or harness to pre-select one. Only engines in the agent's available_harnesses are offered. The chosen harness is locked for the session's lifetime — switching it after a session starts has no effect.
<tai-chat agent="nezasa/tai-assistant" show-harness-switch></tai-chat> <tai-chat agent="nezasa/tai-assistant" harness="THUNDER_AI"></tai-chat>
Localization
The SDK ships English plus built-in packs and resolves missing keys to English, so a partial override is always a safe drop-in. Per-UI strings are controlled client-side (below); per-agent content (welcome_message, init_suggestions) is localized server-side from the request's Accept-Language — see What's not in strings.
React — pass a pack (or a Partial<TaiChatStrings>) to the provider's strings prop. Hoist the value to a module constant (or useMemo keyed on host state): the provider memoises its context on the strings reference, so an inline literal re-renders every consumer on each keystroke.
import { de } from "@nezasa/tai-chat/locales";const STRINGS = { ...de, sessionsHeader: "Meine Chats" }; // module-level — stable<TaiChatProvider config={{ apiBaseUrl, authToken }} strings={STRINGS}>
<TaiChat />
</TaiChatProvider>;
Web Component — declarative language="de" picks a built-in pack with no script; the strings property overrides individual keys (and wins per-key over language). Unknown language codes warn and fall back to English.
<tai-chat language="de" agent="your-agent-id"></tai-chat>
<script type="module">
import { de } from "@nezasa/tai-chat/locales";
document.querySelector("tai-chat").strings = { ...de, sessionsHeader: "Meine Chats" };
</script>
Switching at runtime. Mirror your host's language (URL locale, profile, i18next, …) into strings and the chat re-renders in place — no remount, no session loss. Pre-import the packs into a lookup keyed by code with an en fallback, and reassign on change. Built-in packs (en, de, fr, pt, it, es, nl, fi, sv, no, da, pl, cs, tr) have stable module identity; inline literals don't.
import { de, en, fr } from "@nezasa/tai-chat/locales";
const PACKS = { en, de, fr } as const;
const strings = (PACKS as Record<string, TaiChatStrings>)[hostLocale] ?? en;
Custom packs — type your pack against TaiChatStrings (TypeScript enforces full coverage) or Partial<TaiChatStrings> to fill in incrementally. Keys with interpolated values (counts, dates) are functions so you control plural rules and word order without a template parser — e.g. showMore: (n) => string, messageCount: (n) => string, sessionTimeToday: (date) => string. The TaiChatStrings type ships with the package — import it for the full ~80-key list with editor autocomplete and full type-checking.
Date strings caveat. sessionTimeToday / sessionTimeWeek / sessionTimeOlder use date-fns's English weekday/month names across every shipped pack (intentional). Override those three function-valued keys with your own formatter if you need locale-aware date text.
What's not in strings
Per-agent content — welcome_message and init_suggestions — is localized server-side, not via strings. The backend reads the request's Accept-Language (browsers send it automatically), picks the best match from the agent's per-locale map, and serves the flat fields the SDK already consumes. Hosts in a non-browser runtime (or integration tests) can force a locale with ?locale=de on agent API calls; unsupported codes fall back to en. See Agents for how per-agent content is configured.
Theming
The SDK's CSS is scoped under the [data-tai-chat] attribute the root writes on its own container, so host styles don't bleed into the chat and vice versa. Theme via the theme prop (React) or by targeting the CSS variables directly.
<TaiChatProvider config={config} theme={{ primaryColor: "221 83% 53%", borderRadius: "0.75rem" }}>
<TaiChat />
</TaiChatProvider>
Important: color values are space-separated HSL triplets ("221 83% 53%"), not hex or rgb(). The SDK composes colors with opacity via hsl(var(--primary) / 0.4), which needs the raw triplet — hex silently breaks theming.
Theme key | CSS variable | Format | Default |
|
| HSL triplet | Nezasa red |
|
| HSL triplet | white / dark slate |
|
| HSL triplet | near-black / near-white |
|
| HSL triplet | light gray / dark slate |
|
| HSL triplet | light / dark border |
|
| CSS length |
|
|
| font stack | Mulish / system |
| — |
|
|
Raw-CSS equivalent: [data-tai-chat] { --primary: 221 83% 53%; --radius: 0.75rem; }.
Dark mode is picked up from prefers-color-scheme. Force it with theme={{ colorScheme: "dark" }} (writes data-tai-chat-theme="dark", overriding the OS) — this matters for Mermaid diagrams and Vega charts alike, whose palette is baked into the SVG from the resolved colorScheme.
Web Component theming. Each element renders in an open shadow root, so host resets (button { margin: 0 }, * { box-sizing }) can't collide with the SDK's utilities. The compiled CSS is parsed once into a shared CSSStyleSheet adopted via adoptedStyleSheets (nothing is appended to document.head). CSS custom properties inherit through shadow roots, so the theme tokens still cross the boundary — set them at a scope covering the element (e.g. :root), or pass theme to override.
React hooks (advanced)
To render custom UI alongside chat (a dashboard tile, a "recent conversations" list), the React package exports its React Query hooks — all using the provider's QueryClient:
import { useRecentSessions } from "@nezasa/tai-chat";function RecentSessionsTile() {
const { data, isLoading } = useRecentSessions();
if (isLoading) return <Spinner />;
return <ul>{data?.data.map(renderSession)}</ul>;
}
Exported: useRecentSessions, useChatSessions(agentId), useChatSession(sessionId), useChatMessages(sessionId), useCreateSession, useDeleteSession, useRenameSession, useStarSession, useUnstarSession, useStarredSessions.
Web Component interop
Multiple elements on one page — each instantiates its own
TaiChatProvider+QueryClient, matching the React per-instance model; sessions and cache aren't shared across two tags.Browser support — custom elements are baseline in every evergreen browser. IE11 is not supported.
SSR — out of scope (custom elements are client-only). If you SSR the host, render the element after hydration.
Content Security Policy — CSS lives on
adoptedStyleSheets, not<style>elements, so a strictstyle-src(no'unsafe-inline') works without configuration. No'unsafe-eval'needed.class-namevsclass—class-namemaps to the React component'sclassName(styles inside the React root); the nativeclassstyles the custom element's own box. Useclass-nameto pass a class into the chat,classto position the element.
Troubleshooting
npm install fails with 401 Unauthorized. Most common cause: the PAT isn't SSO-authorized for nezasa. Open the token at https://github.com/settings/tokens → Configure SSO → Authorize next to nezasa, and re-run. Other causes: missing read:packages, expired token, $NPM_TOKEN not set in the shell. See GitHub PAT + SSO authorization.
The chat renders blank / unstyled (React). You likely forgot import "@nezasa/tai-chat/style.css" once in your app, or your bundler isn't honoring the SDK's "sideEffects" entry.
Tailwind classes like max-w-3xl do nothing (React, Tailwind 4). Tailwind's auto-content detector skips node_modules — add @source "../../node_modules/@nezasa/tai-chat/dist/**/*.js"; to your Tailwind entry.
401 loops. onAuthError should refresh the token and let the SDK retry on the next user action — don't remount <TaiChat> on every error.
Session doesn't persist across reloads. Sessions are server-side; the SDK picks the most recent on mount. To re-open a specific one, pass its id down via sessionId / session-id.
Versioning & backward compatibility
The SDK follows semantic versioning. Pin a caret range ("^1.0.0") for safe patch + minor updates; read the changelog before a major. Only the documented components, props, attributes, hooks, and CSS variables are public — don't rely on internal exports or the shape of dist/.
Support
For questions, bug reports, or feature requests about the SDK, contact your Nezasa representative.
