sgiant-ai-widget
v1.22.0
Published
Framework-agnostic embeddable AI chat widget. One call mounts a streaming chat bubble into any page — no React, no framework, themeable.
Maintainers
Readme
sgiant-ai-widget
A framework-agnostic, embeddable AI chat widget. One call mounts a floating bubble and a streaming chat panel into any page.
No React. No framework. Vanilla DOM, themeable through CSS custom properties, and one runtime dependency.
npm install sgiant-ai-widgetLive examples, all plain HTML with no bundler and no build step:
- Quick start → — a
<script src>tag and one call. - Beyond streaming text → — live activity steps, the assistant asking the human to decide, a confirm-gated write, suggestion chips, a form the model designed, a card with its own actions, and a renderer you register yourself. Every demo runs a real turn and shows the JSON that produced it.
- The chrome → —
launcher states (
resting,unread,working,offline,parked) driven live, what the user can do to the panel, and an animated robot avatar that is one inline SVG with its ownprefers-reduced-motionguard. - Advanced mode →
— the assistant highlighting, filling and clicking inside a real app in an
iframe, driven over
sgiant-ai-agent-bridge. Selectors never cross the wire; only ids the page opted in withdata-ai-target. - Real charts → — animated bars, an animated line with a comparison overlay, and a donut, drawn with no chart library at all.
- Theming → — launcher geometry as custom properties, and an inline SVG brand mark.
Two ways in
As a module (React, Next, Vue, Svelte, plain ESM)
import { createAiChatWidget } from "sgiant-ai-widget";
const chat = createAiChatWidget({
endpoint: "https://api.example.com/chat",
title: "Aria",
greeting: "Hi! Ask me anything.",
accent: "#6d28d9",
});
chat.open();As a <script> tag, no build step
<script src="https://unpkg.com/sgiant-ai-widget/dist/sgiant-ai-widget.global.js"></script>
<script>
SgiantAiWidget.createAiChatWidget({ endpoint: "/chat" });
</script>The global build bundles its one dependency, so it is a single file. If your page already uses a bundler, prefer the module build — it shares that dependency instead of carrying a second copy.
Charts, if you have no chart library
The widget draws a stat tile for a kpi and a table for anything else. That is
deliberate: a host with real chart components should mount those through
renderDataWidget, and a core carrying a second implementation would charge
every consumer for one they do not use.
For surfaces with neither — a WordPress admin page, a plain embed — there is an opt-in renderer:
import { createChartFallback } from "sgiant-ai-widget/charts";
createAiChatWidget({ renderChartFallback: createChartFallback() });<!-- or with no build step at all: a separate 3.8 kB bundle -->
<script src="https://unpkg.com/sgiant-ai-widget/dist/sgiant-ai-widget-charts.global.js"></script>
<script>
SgiantAiWidget.createAiChatWidget({
renderChartFallback: SgiantAiWidgetCharts.createChartFallback(),
});
</script>It draws time_series, breakdown and donut, reading your --aiw-* tokens
so a chart follows the theme, and it plots the column your spec names in
metrics / dimension rather than guessing at one — so the same frame renders
the same numbers here and in a full dashboard renderer. It throws for kpi, table, pivot_grid,
heatmap, scatter and content — which the widget catches and renders with
its built-in instead. A bad heatmap made of rectangles is worse than a readable
table of the same numbers.
It is a floor, not a charting library. Nothing in the main entry point references it, so it costs zero bytes unless you ask for it.
The backend
One POST endpoint that streams newline-delimited JSON. Four frame types matter:
| frame | meaning |
| --- | --- |
| {"type":"text","d":"…"} | append this text |
| {"threadId":"…"} | name the conversation |
| {"type":"done"} | the turn is over |
| {"type":"error","message":"…"} | the turn failed |
| {"type":"error","code":"…","message":"…"} | the turn failed, and message is a sentence you wrote for the reader: it is shown by itself. Add "retry":false to offer no "Try again" |
The full contract, including a dependency-free reference server short enough to read in one go, is in BACKEND.md.
Attachments
Set uploadEndpoint and the composer grows a paperclip. Leave it unset and
there is no attach button at all — an attach control on a surface with nowhere
to put a file fails after the user has chosen one, which is the worst moment
to say no.
createAiChatWidget({
endpoint: "/accounts/acc_1/ai/chat",
uploadEndpoint: "/accounts/acc_1/assets/media", // POST multipart
});getUploadEndpoint() is the dynamic form, read at call time, for a host whose
account can change without remounting — the same shape as getToken.
Each file is a multipart/form-data POST carrying file, session=1, and
threadId when there is one; your endpoint answers with a media id, and the
chat turn then carries ids rather than bytes. session=1 is the whole
difference between a chat attachment and an upload to the asset library: a file
dropped into a conversation belongs to that conversation, reachable from its
history by id and never filed alongside deliberate library uploads.
| limit | value | why | | --- | --- | --- | | files per turn | 6 | Extras are dropped, not refused — the turn still sends. | | image / document | 25 MB | Read inline by the model, so this bounds a prompt. | | video / audio | 100 MB | Referenced by id, never inline. A 30-second 1080p phone video is already past the document cap, so a shared cap would refuse the everyday case for being ordinary. |
An oversize file gets its own error chip naming the file and the cap, and the rest of the same selection still uploads — one bad file does not lose the batch.
See examples/uploads.html.
When there is no token to give
getToken is asked before every send. Sometimes the honest answer is that
there is no token and nothing has gone wrong: this person has not connected
their account yet, or has no access to this one. Say so by rejecting with
something that has a userMessage, and optionally one button:
createAiChatWidget({
endpoint: "/chat",
getToken: async () => {
const res = await fetch("/my-site/token");
if (res.status === 409)
// A plain object is enough; so is an Error with these set on it.
throw {
userMessage: t("connectToUse"), // shown as it is
action: {
label: t("connect"),
// Called inside the click, so a window opened here is not blocked.
// Resolve true once a token can be had; false if they backed out.
onClick: () => openConnectWindow(),
},
};
if (!res.ok) throw new Error(`token ${res.status}`); // a real failure
return (await res.json()).token;
},
});The window shows the sentence plainly, with the button under it. It is not the
error card: there is no "hit a snag" headline, no "Try again", and the launcher
is not marked offline. The question is not sent. It stays on screen as the
person's own, and when onClick resolves true the widget asks getToken
again and sends it, once; they do not retype it. Only true counts: resolve
anything else, or reject, and the notice stays with its button working again.
While onClick is running the button is disabled and nothing is sent.
There is one notice at a time. If the second ask is refused too (connected, but
no access), that sentence replaces the first. Leave action out and only the
sentence shows.
One question is held, the latest. If the person asks a second question while the notice is up, that one replaces the first as the held question; the first stays on screen as theirs and is not sent when they connect.
The words are yours, in the page's language; the widget adds none. The button is not offered to the assistant as a control it can press.
Reject with anything that has no userMessage and it is a failure, shown as
one, exactly as before. A notice belongs to a send: a refused token during a
file upload shows nothing, as a failed upload always has. A widget older than
1.20.0 does not know this shape and shows its ordinary error card for it.
Theming
The entire stylesheet reads from --aiw-* CSS custom properties set on the
widget's roots. accent / gradient are the shorthand; the theme option
overrides any token — or theme from plain CSS by targeting the roots, which
works because the defaults are declared inside :where() and therefore carry
zero specificity. Any rule you write beats them, with no !important needed.
Launcher geometry is tokenised too: --aiw-launcher-size, -offset,
-offset-sm (below 480px), -icon, -pill-height, -pill-icon,
-parked-size, -parked-icon, -dot, -dot-sm. The -sm pair is
deliberately independent, so a larger desktop launcher does not force a larger
one on a phone. See examples/theming.html.
createAiChatWidget({
// …
accent: "#6d28d9",
theme: {
// dark mode
surface: "#16161e",
"surface-2": "#22222c",
bg: "#101016",
text: "#e7e7ec",
"text-2": "#b6b6c2",
muted: "#8a8a96",
border: "#2b2b36",
"border-strong": "#3a3a48",
"border-soft": "#22222c",
},
});Tokens: accent, accent-contrast, gradient, surface, surface-2, bg,
text, text-2, muted, border, border-strong, border-soft.
Custom renderers (extension point)
The assistant can emit [[<tag>:{json}]] directives; built-ins cover
widget (stat/kpis/list/table), preview (sandboxed HTML), navigate,
action, chips, and form. Register your own tags to render anything else —
the widget strips the directive, mounts a host element in the log, and hands
you the parsed JSON:
const chat = createAiChatWidget({
// …
renderers: {
calendar: (host, spec) => {
host.textContent = renderMyCalendar(spec); // validate spec yourself
return () => cleanup(); // optional disposer (message cleared)
},
},
});
// or later:
chat.registerRenderer("map", (host, spec) => drawMap(host, spec));Built-in tags are reserved (a plugin can't hijack the confirm-gated
action/form security model). A throwing renderer removes its host card
instead of breaking the message.
Letting people choose the model
Pass models and a small pill appears in the composer, next to Send. The chosen
option's id is sent as model with every message.
createAiChatWidget({
endpoint: "/api/chat",
models: [
{ id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", hint: "Fastest, lightest", cost: "1×" },
{ id: "claude-sonnet-5-5", label: "Sonnet 5.5", hint: "Fast and capable", cost: "2×" },
{ id: "claude-opus-5-5", label: "Opus 5.5", hint: "Most capable", cost: "4×" },
],
defaultModel: "claude-sonnet-5-5",
onModelChange: (id) => { /* optional */ },
});The widget knows nothing about models. Which ones exist, which company serves
each and what they cost belongs to your server; the options are plain data, and
id is opaque. Give options a group (the vendor, say) and they are listed
under it, so offering a second vendor's models is a longer list rather than a
widget change.
The choice is remembered per storageNamespace and layout scope, and checked
against models on every mount: a model you stop offering is never sent again,
the person gets defaultModel instead. Omit models and there is no pill and
no model field. See examples/models.html.
If your list of models failed to load and you would rather open the chat than
hold it back, pass modelsUnavailable: true instead of models. The pill is
drawn where it would be, chooses nothing, and says why to a mouse, a keyboard,
a screen reader and a tap (the modelPickerUnavailable label). No model
field is sent, so the turn runs on your server's default.
In the page's language
labels carries the words; locale carries how a figure is written.
createAiChatWidget({
// …
locale: i18n.language, // a BCP 47 tag: "tr", "en-GB"
labels: resolveWidgetLabels(t), // your translations of the widget's own chrome
});The numbers and times the widget writes itself follow locale: credits left, a
turn's counts, "3 hours ago" in the conversation list. Omit it and they follow
the browser, which is another language whenever someone reads a Turkish page in
an English browser: "24,000" for 24.000, and "8/1/2026" for 1 August. A tag the
engine refuses falls back to the browser's instead of throwing.
The widget carries its own words in English and Turkish. For every label you
do not pass in labels, it uses its own for the page's language: Turkish when
locale is a Turkish tag, or, when you pass no locale, when the page's own
<html lang> is. So a host that passes neither gets a Turkish window on a
Turkish page and an English one everywhere else. For any other language, pass
labels; a label you leave out is shown in English.
A label that says {name} is given the window's own name (title), so a
sentence about what the assistant does names your assistant.
Six words are options of their own, older than the label list: voteUpLabel,
voteDownLabel, editLabel, regenerateLabel, cancelLabel, saveLabel.
They are chosen the same way: yours, else the window's own for the page's
language. subtitle, the small line under the name, has no default: pass one
or nothing is drawn there.
Charts without React
In-app hosts wire renderDataWidget (real @sgiant/ui charts). A standalone
embed has no React, so render_chart frames degrade to a stat/table — unless
you plug renderChartFallback(host, spec, rows, comparisonRows) with any
lightweight chart lib.
Transport
POSTs { accountId, threadId, content } (plus model when the host passes
models; see BACKEND.md) and reads a streamed body, tolerating
both shapes the platform emits:
- SSE —
data: {"type":"assistant_delta","text":"…"} - NDJSON —
{"d":"…"}
plus {type:"thread",threadId}, {type:"error",message}, {type:"done"}.
Security
The widget holds no long-lived secret. In-app surfaces pass a Clerk session
token; external sites pass no token at all — they identify themselves with a
public site key in the POST body (extraBody: { siteKey }), which the
backend verifies against the paired site and rate-limits per site on top of
the per-IP ceiling. An unknown or inactive key is a 403, never a silent fall
back to anonymous. The backend's OBO scoping is the real ceiling; this is
presentation only.
This section said until 2026-09-22 that external sites use "a short-lived
embed token" and that the endpoint minting it "is the next backend step".
There is no such endpoint and there never was: the site-key design shipped
instead (sgiant-platform#339), and the sgiant-connect WordPress plugin has
been emitting extraBody: { siteKey: … } since.
External embedding
The single-file IIFE build ships: npm run build:global writes
dist/sgiant-ai-widget.global.js, exposed as the ./global export and as
window.SgiantAiWidget (see "As a <script> tag" above) — NOT
window.SgiantChat, which never existed. A third-party page needs no token:
pass its public site key and the endpoint does the rest — see Security.
<script src="https://unpkg.com/sgiant-ai-widget/dist/sgiant-ai-widget.global.js"></script>
<script>
SgiantAiWidget.createAiChatWidget({
endpoint: "https://api.example.com/public/ai/chat",
extraBody: { siteKey: "…" },
});
</script>Flagging a conversation
Pass onFlag to put a flag control in the header: clicking it asks for a
reason and calls back with the reason plus the live thread id. Wire it to a
flags API (POST /admin/ai/conversations/:kind/:threadId/flag for staff
surfaces, POST /public/ai/threads/:threadId/flag for the visitor widget).
Omit it and the control does not render.
The surface manifest (sgiant-ai-widget/manifest)
An opt-in entry point re-exporting the bridge's generateManifest,
verifySurface, canAct and hashManifest. It is separate on purpose: the
core is one runtime dependency with no React, and a host that never generates
or verifies a manifest should pay nothing for the machinery. Importing the
main entry pulls none of it.
About the #123 references in the source
The comments cite issue numbers from the private tracker this widget was built in, where it lived inside a monorepo until 2026-09-02. They are kept because the reasoning around them is worth more than the tidiness of removing it — a comment explaining why a wire value must not change is useful even when you cannot open the ticket it cites.
Nothing behind those numbers is needed to use, read, or modify this package.
Licence
MIT © Vedat Aydın Uğur
