Skip to main content

Developer Docs

Written by Product Management

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

@nezasa/tai-chat

React 18 / 19 hosts

import { TaiChat } from "@nezasa/tai-chat"

@nezasa/tai-chat-element

Any other host (Ember, Vue, AngularJS, plain HTML)

<tai-chat> custom element

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 agent

  • Agent 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

react / react-dom (^18 or ^19), @tanstack/react-query ^5

none — React, ReactDOM, TanStack Query are bundled

Stylesheet

import "@nezasa/tai-chat/style.css" once

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.

  1. Create a classic PAT. GitHub Packages for @nezasa needs a classic token (fine-grained tokens don't support the Packages API yet). At https://github.com/settings/tokensGenerate new token (classic), check read:packages only (not write:packages / repo — a broader token is a bigger blast radius), and copy the ghp_… value.

  2. SSO-authorize it. On the token's page, click Configure SSO → Authorize next to nezasa and complete the SSO redirect. Without this the install fails with a misleading 404-like 401.

  3. 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 or secrets.GITHUB_TOKEN).

  4. Verify before a full install:

    NPM_TOKEN=ghp_… npm view @nezasa/tai-chat@latest version

    A version number means auth works; 401 means the token is missing, expired, lacks read: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

latest (default)

Tagged releases (vX.Y.Z), semver-stable

npm install @nezasa/tai-chat

dev

Immutable snapshot of every commit on main (X.Y.Z-dev-<ts>-<sha>)

npm install @nezasa/tai-chat@dev

pr-<N>

Snapshot of an in-progress change; the tag floats to its latest push

npm install @nezasa/tai-chat@pr-214

  • 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 bundlernpm 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

<TaiChat>

<tai-chat>

Full-page chat, embedded panel

Drawer

<TaiChatDrawer>

<tai-chat-drawer>

Slide-in side panel (Jira-style)

Widget

<TaiChatWidget>

<tai-chat-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 (apiBaseUrlapi-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

config

TaiChatConfig

required

API base URL + auth (below)

theme

TaiChatTheme

{}

Theme overrides (see Theming)

queryClient

QueryClient

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

agent

string | string[] | undefined

undefined

Fixed agent (string), allow-list (array), or all agents (undefined)

sessionsLayout

"sidebar" | "accordion" | "hidden"

varies by surface

How the sessions list is surfaced (see Sessions layouts)

showSessions

boolean

Deprecated alias; false forces sessionsLayout="hidden"

showExport

boolean

true

Export dropdown (Markdown / ZIP / PDF)

showTechnicalExport

boolean

false

Adds the "Include technical details" export option (Nezasa-internal; the server rejects the technical payload for non-Nezasa principals regardless)

showTraceExport

boolean

false

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)

showThinking

boolean

true

Thinking and tool-step blocks

showLayoutSwitch

boolean

false

Chat-column width toggle (only while a conversation is active)

showStarring

boolean

false

Per-agent star toggles + a "Starred" filter (requires a user identity)

showHarnessSwitch

boolean

false

Harness selector (see Harness selection)

harness

HarnessType

Pre-select the harness for new sessions

sendButtonPosition

"left" | "right"

"right"

Side of the textarea the send / stop button sits on (see Send-button position)

onLinkClick

(href: string) => boolean | undefined

Claim a link click in an agent answer and route it yourself (see Routing links in place)

className

string

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

open

boolean

required

Visible or not

onClose

() => void

required

Backdrop click or Escape

width

string

"420px"

Drawer width

showBackdrop

boolean

true

Translucent backdrop

onRequestExpand

() => void

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

position

"bottom-right" | "bottom-left"

"bottom-right"

FAB corner

label

string

"Chat"

FAB label

width

string

"400px"

Popup width

height

string

"600px"

Popup height

offsetBottom

string

"1.5rem"

Distance from the bottom edge (see Clearing a fixed corner element)

onRequestExpand

() => void

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

api-base-url

string

yes

TAI backend URL

auth-token

string

yes

JWT for TAI's auth middleware

agent

string

no

Fixed agent id. For allow-list mode set element.agent = [...] as a property

sessions-layout

string

no

"sidebar" / "accordion" / "hidden"

show-sessions

boolean

no

Deprecated alias — false forces sessions-layout="hidden"

show-export

boolean

no

Export dropdown

show-technical-export

boolean

no

Adds the "Include technical details" export option (Nezasa-internal; server-gated)

show-trace-export

boolean

no

Adds the "Diagnostics" trace section (Nezasa-internal; server-gated)

show-thinking

boolean

no

Thinking / tool-step blocks

show-layout-switch

boolean

no

Layout menu (chat width + message alignment)

harness

string

no

Pre-select "CLI" / "THUNDER_AI" / "SUPER_BRAIN"

show-harness-switch

boolean

no

Harness selector dropdown

init-message

string

no

Markdown greeting; overrides the agent's welcome_message

session-id

string

no

Resume a session (controlled; see Session continuity)

default-session-id

string

no

Seed the initial session once at mount (uncontrolled)

send-button-position

string

no

"left" / "right" (default "right") — see Send-button position

class-name

string

no

Extra CSS class on the React root (distinct from native class; see Interop)

<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

onRequestExpand

Send the conversation to a full-page surface

Widget

onRequestExpand

Send the conversation to a drawer or full page

Inline

onRequestCollapse

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:

  • href is 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

initMessage

init-message / .initMessage

string

Markdown greeting on a fresh chat

initSuggestions

.initSuggestions (property-only)

string[]

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

primaryColor

--primary

HSL triplet

Nezasa red

backgroundColor

--background

HSL triplet

white / dark slate

textColor

--foreground

HSL triplet

near-black / near-white

surfaceColor

--card

HSL triplet

light gray / dark slate

borderColor

--border

HSL triplet

light / dark border

borderRadius

--radius

CSS length

0.5rem

fontFamily

--font-sans

font stack

Mulish / system

colorScheme

"light" | "dark" | "auto"

"auto"

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 strict style-src (no 'unsafe-inline') works without configuration. No 'unsafe-eval' needed.

  • class-name vs classclass-name maps to the React component's className (styles inside the React root); the native class styles the custom element's own box. Use class-name to pass a class into the chat, class to 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/tokensConfigure 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.

Did this answer your question?