Chat widget¶
The chat widget is the page-side half of a deployed agent: a floating
launcher or an inline panel that sends each message to the agent server’s
streaming endpoint and renders the reply as it arrives. It consists of two
static files — kaval-chatbot.js and kaval-chatbot.css — with no build
step and no dependencies, and it ships inside the kavalai wheel as the
package kavalai.widget. The in-browser playground of
Running in the browser, which runs a whole workflow inside the
page, is a separate component.
Replies are parsed as Markdown into data and rendered through DOM nodes,
never through innerHTML, so agent output cannot introduce markup. Links
become anchors only for http(s), mailto, site-relative and fragment
targets; any other scheme stays plain text.
Loading the files¶
From a Python application¶
The files are package data, so a Python host serves the version whose
connector speaks the protocol of the agent server it runs.
kavalai.widget.widget_dir() returns their directory, which FastAPI’s
StaticFiles serves as it is:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
from kavalai.widget import widget_dir
app = FastAPI()
app.mount("/widget", StaticFiles(directory=widget_dir()), name="widget")
client = TestClient(app)
for name in ("kaval-chatbot.js", "kaval-chatbot.css"):
response = client.get(f"/widget/{name}")
print(response.status_code, response.headers["content-type"], name)
200 text/javascript; charset=utf-8 kaval-chatbot.js
200 text/css; charset=utf-8 kaval-chatbot.css
The directory also holds the package’s __init__.py. A host that serves
exactly the two files uses asset_path(name) with a FileResponse
route instead: it accepts only the names in WIDGET_FILES and raises
ValueError for any other. widget_dir() raises RuntimeError when
kavalai is imported from a zip archive, where there is no directory to
serve.
From a CDN¶
A page without a Python backend loads the files from jsDelivr, which serves
any tagged revision of the GitHub repository. Releases are tagged
vX.Y.Z; pin the tag of the kavalai release the agent server runs, so
that the connector and the server speak the same protocol:
https://cdn.jsdelivr.net/gh/Kaval-AI/kavalai@v1.0.4/kavalai/widget/kaval-chatbot.js
https://cdn.jsdelivr.net/gh/Kaval-AI/kavalai@v1.0.4/kavalai/widget/kaval-chatbot.css
A branch name in place of the tag would change the files under the page with every commit. The widget is not published to npm: a second release process for two files would add a way for the widget and the server to drift apart and nothing else.
Mounting¶
KavalChatbot.mount(options) builds the widget and returns an object for
controlling it. Floating mode, the default, places a launcher in the
bottom-right corner that opens a resizable window; below 768 pixels of
viewport width the open window covers the screen.
<link rel="stylesheet" href="/widget/kaval-chatbot.css">
<script src="/widget/kaval-chatbot.js"></script>
<script>
const widget = KavalChatbot.mount({
connector: KavalChatbot.agentConnector({ url: "/stream_agent" }),
texts: { title: "Acme support" },
suggestions: ["Opening hours?"],
});
</script>
Inline mode fills a host element, which must have a height of its own. It has no launcher, greeting or maximise button:
KavalChatbot.mount({
connector: KavalChatbot.agentConnector({ url: "/stream_agent" }),
mode: "inline",
target: "#support-chat",
});
Option |
Default |
Meaning |
|---|---|---|
|
required |
An |
|
|
|
|
|
Element or selector the widget is appended to; required inline. |
|
|
Replacement strings; see Texts. An unknown key raises. |
|
— |
Shorthands for the |
|
|
|
|
none |
Image URL shown in the header and on the launcher. |
|
|
Choice chips offered before the first message. |
|
|
|
|
none |
|
|
|
Offers a comment box after the first vote on an answer. |
|
|
Retries of a turn the server never started; see Retries. |
|
|
Delay before the first retry; it doubles with each further one. |
mount returns {root, open, close, toggle, send, reset, destroy,
setStatus, setTheme, on}. send(text) sends a message as if typed,
reset() clears the history and starts a new conversation,
setStatus(text) shows a short line under the title (an empty string
hides it), setTheme(theme) replaces the theme wholesale, and
on(name, listener) subscribes to Events.
The connector¶
KavalChatbot.agentConnector(options) speaks the SSE protocol of the agent
server (Agent Server API): it posts {"external_id", "data"} to the
stream endpoint and reads the reply out of the partial frames as they
arrive. python -m kavalai.server serves that endpoint at
/stream_agent, and an application that mounts create_agent_router
under a prefix serves it at <prefix>/stream_agent.
Option |
Default |
Meaning |
|---|---|---|
|
|
The stream endpoint. The default is the path of the kaval.ai
website and is kept for it; other deployments set |
|
|
Field of the workflow input that receives the message. |
|
|
Field of the workflow output that holds the reply. |
|
|
Field of the workflow output that holds the follow-up choices. |
|
|
Where the conversation id lives; see Storage. |
|
|
The storage key of the conversation id. |
|
none |
An object of request headers, or a function returning one (or a promise of one), called before every request. |
|
none |
|
|
|
The |
The returned connector has send(text, onPartial), reset(), which
starts a new conversation, conversationId() and
setConversationId(id). A header function is the place for a token that
expires, because it runs again before each message; onResponse is the
place to adopt a conversation id the server issues, because it sees the
response headers before anything else does:
const connector = KavalChatbot.agentConnector({
url: "/agents/support/stream_agent",
headers: async () => ({ Authorization: "Bearer " + (await getToken()) }),
onResponse: (response, self) => {
const issued = response.headers.get("X-Conversation");
if (issued) self.setConversationId(issued);
},
});
setConversationId(null) starts a new conversation, as reset() does.
Other backends¶
A connector is any object with send(text, onPartial) returning a promise
of {text, choices, runId}, and optionally reset(). onPartial
receives the reply so far, and an empty string discards what has streamed.
An error thrown as KavalChatbot.UserError is shown to the visitor
verbatim and never retried; any other error is shown as the error text,
so internal detail does not reach the page. An error with
retryable = false is not retried either.
Retries¶
The widget sends a failed turn again only when the server never started it:
the connection failed, the server answered with a status of 500 or above
(503 excepted), or the stream ended before its first frame. It waits
retryDelayMs before the first retry, doubles the delay each time, and
gives up after maxRetries; the typing indicator stays up meanwhile.
Once a frame has arrived, the run exists on the server and its model calls
have been paid for, so a second request would pay for them again. A stream
that breaks or ends without a reply after that point ends the turn with the
interrupted text, and a workflow_failed event ends it with the
error text. Statuses below 500 are not retried: the server has answered
about the request itself, and a repeat receives the same answer. 429 and 503
are shown as rateLimited and unavailable.
Texts¶
Every string the widget shows or announces to assistive technology is a key
of texts, error messages included, so a page is translated by passing
them:
KavalChatbot.mount({
connector: connector,
texts: {
title: "Klienditugi",
disclosure: "Tehisintellekti assistent",
placeholder: "Kirjuta sõnum…",
send: "Saada",
},
});
Key |
Default |
Where it appears |
|---|---|---|
|
|
Window header. |
|
|
Label under the title; see Disclosure. |
|
|
Invitation beside the launcher; |
|
|
History area before the first message. |
|
|
Placeholder of the message input. |
|
|
Accessible name of the message input. |
|
|
Send button. |
|
|
Accessible name of the closed launcher. |
|
|
Accessible name of the open launcher and of the header close button on narrow screens. |
|
|
Accessible name of the maximise button. |
|
|
Accessible name of that button while maximised. |
|
|
Accessible name of the typing indicator. |
|
|
Accessible name of the thumbs group. |
|
|
Accessible name and tooltip of the thumbs-up button. |
|
|
Accessible name and tooltip of the thumbs-down button. |
|
|
Placeholder and accessible name of the comment box. |
|
|
Button of the comment box. |
|
|
Replaces the comment box once a comment is sent. |
|
|
Any failure that is not a |
|
|
The server answered 429. |
|
|
The server answered 503. |
|
|
The stream broke after the run had started. |
A UserError whose code is rateLimited, unavailable or
interrupted is shown with the corresponding text rather than its own
message, which is how the connector’s errors follow the page’s language.
Disclosure¶
The header carries a label, AI assistant by default, that tells the
visitor the other party is an AI system. It takes the header’s colours, so
it remains legible under any theme. Article 50(1) of the EU AI Act
(Regulation (EU) 2024/1689) requires a system intended to interact with
people to be designed so that they are informed of this, unless it is
obvious from the context. The label is therefore on by default, and a
deployment meets that requirement without configuration.
texts.disclosure changes the wording and disclosure: false removes
the label. Whether a site’s context makes the disclosure unnecessary is a
judgement for the site, which knows its visitors, rather than for the widget.
Storage¶
The conversation id is the external_id of every request, so the messages
of one conversation form one session in the agent database. storage
chooses where it is kept:
|
Kept in |
Effect |
|---|---|---|
|
|
One conversation per tab; a new tab starts a new one. |
|
|
The conversation continues across tabs and later visits. |
|
memory |
Nothing is written to the device; a reload starts a new conversation. |
The widget writes nothing else to the visitor’s device. Whether a site may store the id, and for how long, is a consent question that the site answers when it chooses the mode. Where the chosen area is missing or refuses writes, as in some private browsing modes, the id is kept in memory for the page’s lifetime.
Feedback¶
With onFeedback given, a thumbs-up and a thumbs-down button appear under
every answer that carries a run id, which agentConnector takes from the
workflow_started event. Without onFeedback nothing is rendered. The
agent server has no feedback endpoint: feedback is stored by the host,
wherever it keeps its own records, under the run id that identifies the run
in the agent database.
KavalChatbot.mount({
connector: connector,
feedbackComment: true,
onFeedback: (runId, vote, comment) =>
fetch("/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_id: runId, vote, comment }),
}),
});
vote is "up" or "down" and comment is null for a vote.
Each click calls onFeedback at once, and the chosen button carries
aria-pressed="true". With feedbackComment: true a comment box follows
the first vote on an answer; a submitted comment arrives in a second call
with the same run id and the current vote, so a host that keeps one record
per run keeps the latest call. A callback that throws or rejects is logged
to the console and not retried.
Events¶
widget.on(name, listener) subscribes to an event and returns a function
that unsubscribes. An unknown name raises, and a listener that throws is
logged and skipped, so host code cannot leave the widget half-updated.
Event |
Payload |
Fired when |
|---|---|---|
|
none |
The floating window opens. |
|
none |
The floating window closes. |
|
|
An answer is complete; |
|
|
A turn fails; |
|
|
The floating window changes size: maximised, restored, dragged, or shrunk to fit a smaller viewport. |
|
|
The visitor votes or sends a comment. |
A host that places the widget inside an iframe sizes the frame from
open, close and resize; the message protocol between the frame
and its page belongs to the host:
widget.on("resize", ({ width, height }) =>
parent.postMessage({ type: "chat-size", width, height }, pageOrigin)
);
Theming¶
Every colour, font, size and radius is a custom property on
.kcb-chatbot. The theme option and setTheme take them with
camelCased keys (accentContent sets --kcb-accent-content); a
stylesheet sets them on .kcb-chatbot directly. The defaults are the
kaval.ai palette.
Token |
Default |
Used for |
|---|---|---|
|
|
Header, launcher, user messages, buttons. |
|
|
Text and icons on the accent colour. |
|
|
Window and input background. |
|
|
Agent messages. |
|
|
Borders and rules. |
|
|
Body text; also the background of code blocks. |
|
|
Links under the pointer. |
|
|
Window and message corners. |
|
|
Buttons, input and code corners. |
|
|
Body text. |
|
|
The earlier name of the body font, still honoured. |
|
|
Base size; every text size in the widget is a multiple of it. |
|
|
The title and headings inside answers. |
|
|
Code. |
|
|
Shadows of the window and launcher. |
|
|
Stacking order of the floating widget. |
|
|
Height of a fixed page header, under which a maximised window stops. |
Classes¶
The following classes are public: they keep their names and meaning across
minor releases, so a host stylesheet may target them. Every other kcb-
class — resize handles, icons, animation states — is internal.
Root:
kcb-chatbot, withkcb-floatingorkcb-inline;kcb-openwhile the floating window is open andkcb-maximizedwhile it is maximised.Frame:
kcb-window,kcb-header,kcb-title,kcb-title-label,kcb-disclosure,kcb-status,kcb-greetingand the launcherkcb-bubble-float.History:
kcb-history,kcb-empty,kcb-messagewithkcb-from-userorkcb-from-agent,kcb-erroron an agent message that reports a failure, andkcb-typing.Feedback:
kcb-feedback,kcb-feedback-btnwithkcb-feedback-uporkcb-feedback-down,kcb-feedback-comment,kcb-feedback-submitandkcb-feedback-thanks.Input:
kcb-choices,kcb-choice,kcb-input-row,kcb-send.Answers:
kcb-md-paragraph,kcb-md-heading(withdata-level),kcb-md-list,kcb-md-rule,kcb-md-link,kcb-md-strong,kcb-md-em,kcb-inline-codeandkcb-code.
The stylesheet resets the page’s button styles inside the widget with
.kcb-chatbot button and styles its own buttons with two classes, as in
.kcb-chatbot .kcb-send, so an override of a button needs at least that
specificity.
Testing¶
The widget’s tests run under Node without a browser:
$ node --test kavalai/widget/tests/kaval-chatbot.test.js
They cover the Markdown parser, the SSE handling and the connector, and
mount over a minimal DOM. They also check that this page documents every
texts key with its default, every event, every token and every class it
names. kavalai/widget/preview.html shows both modes against a scripted
connector, with ?theme=dark for the dark variant.