@abundly/widget
v1.2.0
Published
Embeddable Abundly chat widget — headless client + ready-made UI for browser embeds.
Readme
@abundly/widget
Embeddable Abundly chat widget for the Abundly AI platform — ready-made UI + headless client.
The widget has two modes of operation:
- Ready-made widget (
mountChatWidget) — a complete, styled floating chat bubble and panel. Use it as-is, or customize colors and texts through configuration. One function call and you're done. - Custom UI (
createClient) — a headless client that handles backend communication, streaming, URL rewriting, and markdown rendering. You build the UI entirely yourself using the building blocks the client exposes.
Both modes work from React (ESM/CJS import) and from plain HTML (script tag). Both share the same backend proxy contract.
Install
npm install @abundly/widgetMode 1: Ready-made widget
mountChatWidget gives you a floating chat bubble, a slide-up panel, streaming
responses with typing animation, a thinking indicator, and image support — no UI
code required.
Both mountChatWidget and createClient are exposed as named exports from
@abundly/widget (for bundlers) and as properties on the Abundly global (for
the IIFE script tag).
Script tag
<script src="https://app.abundly.ai/widget.js"></script>
<script>
Abundly.mountChatWidget({
backendUrl: "https://yoursite.com/api/abundly",
textOptions: { headerTitle: "Chat" },
uiOptions: { bubbleColor: "#319795" },
});
</script>Alternatively, npm install @abundly/widget and use @abundly/widget/iife (minified) or @abundly/widget/iife/dev (readable, for debugging).
React
Mount on a route, unmount on the effect's cleanup so the widget doesn't leak when the user navigates away:
"use client"; // Next.js App Router; omit for plain React
import { useEffect } from "react";
import { mountChatWidget } from "@abundly/widget";
export function ChatBubble() {
useEffect(() => {
const handle = mountChatWidget({
backendUrl: "/api/abundly",
textOptions: { headerTitle: "Support" },
uiOptions: { bubbleColor: "#319795" },
});
return () => handle.unmount();
}, []);
return null;
}Handle
mountChatWidget returns a handle with two methods:
| Method | What it does |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| handle.reset() | Clears conversation history, errors, and suspended state. Restores the welcome message if configured. |
| handle.unmount() | Removes bubble, panel, backdrop, and injected styles. Cancels animations and drops in-flight callbacks. Idempotent, safe to call at any time. |
What the widget handles for you
- Availability check (
/status) — widget stays hidden if the agent is unavailable - SSE streaming with character-by-character reveal animation
- Thinking indicator (animated dots) during tool/agent processing
- Markdown rendering with XSS protection
- URL rewriting (platform file URLs routed through your proxy)
- Image preloading and authenticated image loading (blob URLs)
- Mobile soft-keyboard detection and layout adjustment
- Textarea auto-resize (up to 3 lines)
- Error display and rate-limit suspension (HTTP 429)
Config
interface MountChatWidgetConfig {
backendUrl: string; // required — your proxy URL (path or absolute)
textOptions?: { // all optional, sensible defaults
placeholder?: string; // input placeholder ("Type a message…")
bubbleLabel?: string; // text shown on the floating bubble ("Chat about this")
headerTitle?: string; // chat panel title ("Chat")
welcomeMessage?: string; // initial assistant message (none by default)
};
uiOptions?: { // all optional, default to brand colors
bubbleColor?: string; // bubble background (color or CSS gradient)
bubbleHoverColor?: string;
bubbleTextColor?: string;
userBubbleColor?: string; // user message bubble
inputBorderColor?: string; // textarea focus border
submitColor?: string; // send button background
submitHoverColor?: string;
submitDisabledColor?: string;
headerColor?: string; // chat panel header background
headerTextColor?: string;
linkColor?: string; // assistant-message link color
};
// Optional auth — see the Auth section below for details.
headers?:
| HeadersInit
| (() => HeadersInit | Promise<HeadersInit>);
credentials?: RequestCredentials; // "omit" | "same-origin" | "include"
}Colors accept any valid CSS value — named colors, hex, rgba, gradients.
Mode 2: Custom UI (headless client)
createClient returns the same streaming/protocol engine the ready-made widget
uses internally, without any DOM or styles. You build the UI entirely yourself —
React, Vue, Svelte, or vanilla DOM — using the building blocks the client exposes.
Creating the client
React / bundler:
import { createClient } from "@abundly/widget";
const client = createClient("/api/abundly");Script tag:
<script src="https://app.abundly.ai/widget.js"></script>
<script>
const client = Abundly.createClient("/api/abundly");
</script>The optional second argument configures auth (same options as mountChatWidget):
const client = createClient("/api/abundly", {
headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
credentials: "include", // only needed for cross-origin cookie auth
});Rendering: bundled vs. bring your own
Before diving into the client API, there's a second choice to make: how to render the markdown that the agent produces. The client gives you two paths:
- Path A: Bundled renderer — use
client.renderMarkdownto get an HTML string, feed it todangerouslySetInnerHTML(React) or.innerHTML(vanilla). Zero extra dependencies. XSS protection built in. - Path B: External library — use your own markdown library (e.g.
react-markdown,marked,markdown-it). The streamed text already has URLs rewritten, so no renderer-specific hooks are needed.
This choice only affects how the markdown string is converted into HTML —
styling is always your responsibility in either path. The bundled renderer
emits plain HTML tags (<p>, <ul>, <code>, etc.) with no classes or
inline styles; you style them with your own CSS.
This choice also affects which client methods you need. The table below shows which methods are essential, optional, or unused for each path — detailed descriptions follow in the API reference.
| Method | Path A (bundled) | Path B (external library) |
| ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| checkStatus() | Essential | Essential |
| sendMessage() | Essential | Essential |
| resetSession() | Call when starting a new conversation | Call when starting a new conversation |
| stripPartialMd() | Convenience — cleans up half-formed  | Essential | Not used — your library replaces it |
| swapProxiedUrls() | Header-auth only — rewrites all images in the HTML DOM at once | Header-auth only — prefer loadImage per-image instead |
| loadImage() | Header-auth only — prefer swapProxiedUrls for bulk | Header-auth only — resolve each image URL before passing to your component |
| releaseImages() | On teardown, if you used swapProxiedUrls or loadImage | On teardown, if you used loadImage |
See Choosing a markdown renderer further down for a feature comparison and examples of both paths.
Client API reference
client.checkStatus()
Both paths — essential.
checkStatus(): Promise<{ available: boolean }>Calls GET {backendUrl}/status. Returns whether the agent is available. Use this to decide whether to show your chat UI.
client.sendMessage(text, callbacks?)
Both paths — essential.
sendMessage(
text: string,
callbacks?: SendMessageCallbacks,
): Promise<string>Sends a message to POST {backendUrl}/chat and streams the response via SSE.
Returns a promise that resolves to the final accumulated response text.
The server tracks conversation history automatically via a session ID managed by
the client. Just maintain a local Message[] for rendering your UI:
interface Message {
role: "user" | "assistant";
content: string;
}All text in callbacks and the return value has platform /api/files/... URLs
already rewritten to point at your backend proxy — hand it straight to any
markdown renderer without additional URL transforms.
Callbacks (all optional — the final text is also returned from the promise):
interface SendMessageCallbacks {
onChunk?: (accumulated: string, delta: string, isFirstChunk: boolean) => void;
onProcessing?: (accumulatedBeforeReset: string) => void;
onStreamError?: (errorMessage: string) => void;
onDone?: (finalAccumulated: string) => void;
}| Callback | When it fires | What to do |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| onChunk | Every SSE chunk. accumulated is the full text so far; delta is the new slice; isFirstChunk is true on the very first chunk after a processing reset or session start. | Update your streaming UI. |
| onProcessing | The backend signals a tool/agent step is running and the text buffer resets. accumulatedBeforeReset is whatever text had streamed before the reset. | Finalize the current partial message (if any) and show a thinking indicator. |
| onStreamError | An error embedded inside the SSE stream (not HTTP-level). | Display the error message to the user. |
| onDone | The stream completes cleanly. | Finalize the message. |
Error handling: HTTP-level errors (non-2xx from the chat endpoint) throw an
AbundlyWidgetHttpError:
interface AbundlyWidgetHttpError extends Error {
status: number;
body: { error?: string; [key: string]: unknown };
}Branch on status — for example, 429 means rate-limited.
client.resetSession()
Both paths — call when starting a new conversation.
resetSession(): voidClears the server-side session ID so the next sendMessage call starts a new
conversation. The client manages session continuity automatically — you only need
this when you want an explicit "new conversation" action (e.g. a reset button).
Clear your local Message[] array at the same time.
client.renderMarkdown(text)
Path A (bundled) — essential. Path B (external library) — not used.
renderMarkdown(text: string): stringConverts a markdown subset to an HTML string. This is the same renderer the
ready-made widget uses. Dangerous URL schemes (javascript:, data:,
vbscript:, …) are stripped — only http(s):, mailto:, and relative URLs
pass through.
Supported syntax: paragraphs, headings, unordered/ordered lists, fenced code blocks, tables, bold, inline code, links, images, horizontal rules.
If you are using your own markdown library (Path B), skip this method entirely — your library replaces it. The streamed text has URLs already rewritten, so no renderer-specific URL hook is needed. See Choosing a markdown renderer below.
client.stripPartialMd(text)
Both paths — convenience utility.
stripPartialMd(text: string): stringCosmetic helper for streaming. While text is still arriving, the accumulated
string can end with an incomplete image or link token like , native <img> tags can't carry that header. The browser would fire an
unauthenticated GET to the image URL and get a 401. These three methods solve
that by fetching images with the same auth as /chat and exposing them as
blob: URLs the browser can load without headers.
client.loadImage(url)
loadImage(url: string): Promise<string>Fetches one image with auth, returns a blob: URL you can set on <img src>.
Results are cached per-URL. Only URLs pointing at this client's image proxy are
fetched with auth; external URLs and non-auth setups return the URL unchanged —
safe to call on every image unconditionally. Falls back to the original URL on
fetch failure.
See Path B: image auth for a practical example.
client.swapProxiedUrls(root)
swapProxiedUrls(root: ParentNode): voidWalks root and rewrites the src of every <img> and the href of every
<a> that points at the image proxy, swapping them to blob: URLs. No-op when
auth is not configured. Uses loadImage under the hood — if an image was
already resolved (e.g. from a preload pass during streaming), the swap is
synchronous with no flicker.
See Path A: image auth for a practical example.
client.releaseImages()
releaseImages(): voidRevokes every blob: URL this client has created and clears the cache. Call on
component teardown if you used loadImage or swapProxiedUrls. The ready-made
widget calls this automatically on unmount().
Who is responsible for what
| Concern | The client handles | You handle |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Backend communication (SSE streaming, status check) | Yes | — |
| URL rewriting (/api/files/... → proxy) | Yes — applied in sendMessage before callbacks fire | — |
| Markdown → HTML | Path A: renderMarkdown. Path B: not used. | Choosing a renderer and calling it on the text |
| Streaming partial-markdown cleanup | stripPartialMd (convenience — optional, prevents half-formed […]( flashing during streaming) | Calling it if you want clean streaming output |
| XSS / dangerous URL schemes | Path A: renderMarkdown handles it. Path B: your library's responsibility — verify its defaults. | — |
| Image auth (blob URLs) | Path A: swapProxiedUrls. Path B: loadImage. | Calling the appropriate helper when your proxy requires header-based auth |
| Blob URL cleanup | releaseImages | Calling it on teardown if you used image auth helpers |
| Session management | Yes — persists conversation server-side; call resetSession() to start fresh | — |
| Conversation history | Persisted server-side automatically | Maintaining a local Message[] for rendering your UI |
| UI rendering, layout, styling | — | Everything |
Minimal example (React)
"use client";
import { useState } from "react";
import { createClient, type Message } from "@abundly/widget";
const client = createClient("/api/abundly");
export function MyChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState("");
const [draft, setDraft] = useState("");
async function send() {
const text = draft.trim();
if (!text) return;
setDraft("");
setMessages((prev) => [...prev, { role: "user", content: text }]);
setStreaming("");
// No need to pass conversation history — the server tracks it via the
// session ID that the client manages automatically.
const final = await client.sendMessage(text, {
onChunk: (accumulated) => setStreaming(accumulated),
});
setStreaming("");
setMessages((prev) => [...prev, { role: "assistant", content: final }]);
}
function resetChat() {
client.resetSession();
setMessages([]);
}
return (
<div>
{messages.map((m, i) => (
<div key={i} className={m.role}>
{m.role === "user" ? (
m.content
) : (
<div dangerouslySetInnerHTML={{
__html: client.renderMarkdown(m.content)
}} />
)}
</div>
))}
{streaming && (
<div className="assistant">
<div dangerouslySetInnerHTML={{
__html: client.renderMarkdown(client.stripPartialMd(streaming))
}} />
</div>
)}
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button onClick={send}>Send</button>
<button onClick={resetChat}>New chat</button>
</div>
);
}Choosing a markdown renderer
The agent emits standard markdown. You have two paths to render it, and the choice affects which client methods you use and how you handle images.
Path A: Bundled renderer
Use client.renderMarkdown to produce HTML, feed it to
dangerouslySetInnerHTML or .innerHTML. Zero extra dependencies. XSS
protection and scheme filtering built in.
Methods you need: sendMessage, checkStatus, renderMarkdown.
Add stripPartialMd if you want clean streaming output (no half-formed tokens).
Add swapProxiedUrls + releaseImages if your proxy requires header-based auth.
// Streaming render with the bundled renderer
<div dangerouslySetInnerHTML={{
__html: client.renderMarkdown(client.stripPartialMd(streaming))
}} />The dangerouslySetInnerHTML is a conscious opt-in — the safety contract is
that the widget produces the HTML, with scheme filtering baked in. You're
not trusting agent output; you're trusting the renderer.
Image auth with swapProxiedUrls: if your proxy requires header-based auth,
parse the HTML into an inert <template>, call swapProxiedUrls to rewrite
image URLs to blob: URLs, then move the children into the target element.
Apply this to individual message elements, not the entire message list —
replacing the whole container would re-render all messages and cause images to
flicker. This is how the ready-made widget handles it internally.
// Rendering a single assistant message into its own element:
function renderMessage(el: HTMLElement, text: string) {
const tmpl = document.createElement("template");
tmpl.innerHTML = client.renderMarkdown(text);
client.swapProxiedUrls(tmpl.content);
el.replaceChildren(tmpl.content);
}Call client.releaseImages() on teardown to revoke the blob URLs.
Path B: External library
Use your own markdown library (react-markdown, marked, markdown-it, etc.).
The streamed text already has URLs rewritten by sendMessage, so no
renderer-specific URL hook is needed — hand the text straight to your library.
Methods you need: sendMessage, checkStatus.
Add stripPartialMd if you want clean streaming output (no half-formed tokens).
Add loadImage + releaseImages if your proxy requires header-based auth
(resolve each image URL before passing it to your <img> component). You do
not need renderMarkdown or swapProxiedUrls.
// Streaming render with react-markdown
<ReactMarkdown>{client.stripPartialMd(streaming)}</ReactMarkdown>Image auth with loadImage: if your proxy requires header-based auth,
react-markdown (or any library) would render a plain
<img src="/api/abundly/images/..."> and the browser would try to load it
without auth headers — resulting in a 401. Since you control component rendering
in Path B, override the img component to resolve each URL via loadImage
before the browser sees it:
import ReactMarkdown from "react-markdown";
function AuthImage({ src, alt }: { src?: string; alt?: string }) {
const [resolved, setResolved] = useState(src);
useEffect(() => {
if (src) client.loadImage(src).then(setResolved);
}, [src]);
return <img src={resolved} alt={alt ?? ""} />;
}
<ReactMarkdown components={{ img: AuthImage }}>
{message.content}
</ReactMarkdown>Call client.releaseImages() on teardown to revoke the blob URLs.
Feature comparison
| | Path A: bundled renderer | Path B: external library |
| --------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Bundle size | 0 extra | +library size (e.g. ~25 kB for react-markdown + remark) |
| dangerouslySetInnerHTML? | yes (intentional — we produce the HTML) | no (React elements) |
| Markdown features | headings, lists, bold/italic, inline+fenced code, tables, links, images, hr | whatever the library supports |
| URL rewriting | done for you (/api/files/... already proxied in sendMessage) | done for you (same) |
| XSS / scheme guard | ours (http(s): / mailto: / relative; everything else stripped) | the library's job (most do it by default — verify) |
| Custom components / theming | CSS only; the renderer emits raw HTML tags | full control via React elements |
| Image auth approach | swapProxiedUrls on the DOM fragment | loadImage per-image in your component |
Pick Path A if you want minimal dependencies and a renderer that matches the ready-made widget's output. Pick Path B if you want full markdown features, custom components, or to match an existing renderer in your app.
Backend proxy
The widget never talks to Abundly directly. You run a server-side proxy that holds the API key and forwards requests to Abundly server-to-server:
Browser ──▶ Your backend proxy ──server-to-server──▶ Abundly API
(holds API key) X-Agent-Access-Key- Set
backendUrlto the path or URL of your proxy — a relative path like/api/abundlyfor same-origin setups, or an absolute URL for cross-origin. - The API key stays server-side — never expose it in frontend code.
- Same-origin is the simplest setup (cookies flow automatically). For cross-origin, see the Auth section below.
Set these environment variables on your server (values come from the Abundly portal widget settings):
| Variable | Description |
| --------------------- | ----------------------- |
| ABUNDLY_SERVICE_URL | Abundly service URL |
| ABUNDLY_AGENT_ID | Agent ID |
| ABUNDLY_API_KEY | API access key (ak_…) |
The widget appends these paths to your backendUrl:
| Route | Method | Upstream |
| ---------------- | ------ | ------------------------------------------------------ |
| /status | GET | {SERVICE_URL}/agents/{AGENT_ID}/widget/status |
| /chat | POST | {SERVICE_URL}/agents/{AGENT_ID}/widget/chat (SSE) |
| /images/<path> | GET | {SERVICE_URL}/agents/{AGENT_ID}/widget/images/<path> |
All upstream requests use header X-Agent-Access-Key: {API_KEY}.
For ready-to-use backend proxy examples in Node.js, Express, Next.js, and PHP, see the documentation.
Auth
If your proxy is same-origin and protected by a session cookie, you don't need to configure anything — the browser sends cookies on every request out of the box.
For everything else — Bearer/JWT tokens, custom X-* headers, CSRF tokens, API keys, cross-origin cookies — pass headers and/or credentials on the mount call (or to createClient). Both work the same way in mountChatWidget and in IIFE script-tag usage.
Bearer / JWT (rotating token)
Use the function form so the token is re-read on every request — tokens that rotate (Auth0, Clerk, NextAuth, Supabase, Firebase, custom) won't go stale.
mountChatWidget({
backendUrl: "/api/abundly",
headers: async () => ({
Authorization: `Bearer ${await getAccessToken()}`,
}),
});Static header (API key, tenant, …)
Use the object form when the value doesn't change:
mountChatWidget({
backendUrl: "/api/abundly",
headers: { "X-API-Key": "ak_live_..." },
});Cross-origin proxy with cookies
If your proxy lives on a different origin and authenticates via cookies, set credentials: "include". The proxy must respond with Access-Control-Allow-Credentials: true and an explicit Access-Control-Allow-Origin.
mountChatWidget({
backendUrl: "https://api.example.com/abundly",
credentials: "include",
});Reference
// On MountChatWidgetConfig and createClient's second arg:
headers?:
| HeadersInit
| (() => HeadersInit | Promise<HeadersInit>);
credentials?: RequestCredentials; // "omit" | "same-origin" | "include"headersis spread onto every request the widget makes (/status,/chat, and authenticated image loads). User headers are applied after the built-inContent-Type: application/json, so a user header with the same name wins.credentialsis forwarded directly tofetch. Defaults to the browser default ("same-origin") when omitted.
Images and authenticated proxies
Native <img> tags can't carry an Authorization header — only cookies. If your /images route requires header-based auth, the widget handles this for you: when headers or credentials is configured, image URLs are fetched with the same auth as /chat, exposed as blob: URLs, and swapped onto the rendered <img> and surrounding <a href> tags automatically (so "open in new tab" works).
The bundled mountChatWidget does this for you — no extra code. For custom UIs built on createClient, two helpers are available:
**client.swapProxiedUrls(root)** — preferred. After you render markdown into HTML and insert it into the DOM, call this on the parent node. It walks every<img src>and<a href>pointing at the proxy, swaps the URL to ablob:URL fetched with your auth, and is a no-op when no auth is configured. To avoid the browser ever firing an unauthenticated request for the original URL, parse the HTML inside a<template>first and only attach the children onceswapProxiedUrlshas run:const tmpl = document.createElement("template"); tmpl.innerHTML = client.renderMarkdown(text); client.swapProxiedUrls(tmpl.content); container.replaceChildren(tmpl.content);**client.loadImage(url)**— lower-level. Returns aPromise<string>(the resolvedblob:URL when auth is configured, the original URL otherwise). Useful when you're constructing<img>elements yourself rather than rendering markdown to HTML.
Both helpers cache per-URL and fall back to the original URL on fetch failure. The ready-made mountChatWidget calls client.releaseImages() automatically on unmount(); custom UIs should call it on teardown to revoke the blob URLs.
License
MIT
