Ready-Made Chat UIs
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-10-01 against @assistant-ui/react 0.15.22 (with @assistant-ui/react-ai-sdk 1.4.13),
Chainlit 2.12.0 (released 2026-08-25), Open WebUI 0.11.4 (released 2026-09-21) and LibreChat v0.8.8 (the latest git tag
on that date; the repository publishes tags rather than GitHub releases, and the librechat.yaml schema version used
below, 1.3.13, is the one shown in its documentation), next to the FastAPI 0.142.2 / LangChain 1.4.3 and Spring AI
2.0.1 cores described in the version baseline. The snippets
are illustrative: they are written from the official documentation of each tool and were not compiled or run by this
repository. Licence statements below summarise what the cited licence files and licence pages said on that date and are
not legal advice; read the licence file of the exact version you deploy.
The previous pages built the plumbing: a channel-agnostic core (Channel-Agnostic Architecture), streaming HTTP endpoints (Streaming Chat APIs) and the wire formats that React chat components consume (Chat UI Protocols). This page asks a more pragmatic question: when is it better to not write a front end at all, and which off-the-shelf chat UI should sit in front of DocsAssistant? Building your own chat page is covered in Embedding a Web Chat Widget.
Four tools, three different kinds of thing
The four tools are not interchangeable, and comparing them as if they were is the most common mistake.
-
assistant-ui is a React component library. It gives you the chat thread, composer, message actions, attachments and accessibility, and you host it inside your own application. There is no server to run and no user database: you bring the backend, the authentication and the page around it.
-
Chainlit is a Python application framework for conversational apps. You write a Python module with decorated handlers (
@cl.on_message) and Chainlit serves a ready UI and a WebSocket back end for it. The UI and your handler code run in the same process. -
Open WebUI and LibreChat are complete self-hosted chat products: a server, a database, user management, model connections, conversation history and, in both, file upload with retrieval. You do not write code; you configure them and point them at model endpoints. DocsAssistant becomes one more "model" in their model picker.
That difference decides the integration style. The two products speak the OpenAI Chat Completions protocol to their back ends, so the cheapest integration is to give DocsAssistant an OpenAI-compatible face. The framework and the library let you talk to the core directly, with no compatibility layer.
(React components in your app)"] CL["Chainlit
(Python app + UI)"] OW["Open WebUI
(self-hosted product)"] LC["LibreChat
(self-hosted product)"] end OAI["/v1/chat/completions
(OpenAI-compatible adapter)"] ASK["/v1/ask or in-process ask()"] CORE["DocsAssistant core
ask(conversationId, userId, text)"] OW -->|"Chat Completions + SSE"| OAI LC -->|"Chat Completions + SSE"| OAI AUI -->|"custom adapter or AI SDK route"| ASK AUI -.->|"or"| OAI CL -->|"import and call"| CORE OAI --> CORE ASK --> CORE
Comparison
The table summarises what the official documentation says as of 2026-10-01. "Built-in RAG" means retrieval over files that users upload to the tool itself; it is a different thing from DocsAssistant’s own, access-controlled retrieval, which stays behind the endpoint.
| assistant-ui | Chainlit | Open WebUI | LibreChat | |
|---|---|---|---|---|
What it is |
React component library (TypeScript), plus an optional hosted cloud for persistence |
Python framework that serves a chat UI and runs your handlers |
Self-hosted chat product (web app and server) |
Self-hosted chat product (web app and server) |
Hosting |
Inside your own React or Next.js app; deploy it wherever that app goes |
One Python process ( |
Docker image |
Docker Compose stack (app, MongoDB and further services; the RAG API is a separate container) |
Talks to DocsAssistant via |
A |
Python code in the handler: import the core or call its HTTP API |
An OpenAI-compatible connection (admin setting or environment variables) |
A custom endpoint in |
Built-in RAG |
None; retrieval is whatever your backend does. Attachment components exist for the UI side |
None built in; you wire LangChain or LlamaIndex yourself and can show sources with elements and steps |
Knowledge and document upload with a configurable vector store, embedding model and chunking |
Optional RAG API service (PostgreSQL with pgvector) for user-uploaded files |
Authentication |
Yours: it is a component, so your application’s session decides who is chatting |
Opt-in: password, OAuth and header-based callbacks; a signing secret is required |
Local accounts with roles, signup toggle, OAuth/OIDC, LDAP and trusted-header options in the environment reference |
Local email accounts, OAuth2 and OpenID Connect, LDAP, SAML, social logins |
Extensibility |
Highest: you own the components, can style them freely, add tool UIs and generative UI |
High for Python teams: elements, steps, actions, custom data layers and a copilot embed |
Plugins-style "functions" and "tools", pipelines, model presets; changes mostly via admin UI |
Agents, presets, plugins and MCP support through configuration; code changes mean forking |
Licence |
MIT (see the |
Apache-2.0 (as shown on the repository page) |
Modified BSD-3-Clause with a branding-retention clause since v0.6.6; not OSI-approved by the project’s own words |
MIT (see the |
Maintenance status |
Active commercial-backed open-source project; very frequent releases on npm |
Community-maintained since 2025-05-01; the original team stepped back from active development |
Active; frequent releases (0.11.4 on 2026-09-21) |
Active; the repository was pushed to on the day this page was written, but it uses tags rather than GitHub releases |
The rows that matter most for DocsAssistant are Built-in RAG, Authentication and Licence.
DocsAssistant already does retrieval, with ACL filtering and citation verification
(Access Control & Privacy,
Citations & Grounding). A UI that adds a second, uncontrolled RAG
path for uploaded files is a feature for general-purpose assistants but a leak risk here, so Open WebUI’s knowledge
features and LibreChat’s file search are best switched off, or limited by role, for the DocsAssistant model. The
authentication row matters because the core needs a trustworthy userId to apply document permissions: only
assistant-ui (your own app) and Chainlit (your own callbacks) let you pass a verified identity straight into the core;
with the two products, DocsAssistant sees whatever the product forwards, so decide early whether the endpoint trusts a
single service key (all users share one identity) or per-user tokens.
The DocsAssistant side: an OpenAI-compatible adapter
Open WebUI and LibreChat need POST /v1/chat/completions (streaming with server-sent events) and, ideally,
GET /v1/models. This is a thin adapter over the same ask() core used by every other channel. It maps the last user
message to text, takes the conversation identity from a header or a request field, streams token events as
chat.completion.chunk objects and appends the citations as a Markdown list in the final text chunk, since a generic chat
UI renders Markdown but has no concept of a citation event.
import json
import time
import uuid
from fastapi import APIRouter, Header, Request
from fastapi.responses import JSONResponse, StreamingResponse
from docs_assistant.core import ask # async generator of dict events: token | citation | done
router = APIRouter()
MODEL_ID = "docs-assistant"
@router.get("/v1/models")
async def models():
return {"object": "list",
"data": [{"id": MODEL_ID, "object": "model", "owned_by": "docs"}]}
def chunk(completion_id: str, delta: dict, finish: str | None = None) -> str:
body = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": MODEL_ID,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
}
return f"data: {json.dumps(body)}\n\n"
@router.post("/v1/chat/completions")
async def chat_completions(request: Request, authorization: str = Header(...)):
payload = await request.json()
user = verify_bearer(authorization) # your JWT or API-key check
question = payload["messages"][-1]["content"] # the UI resends the whole history
conversation_id = request.headers.get("x-conversation-id") or str(uuid.uuid4())
if not payload.get("stream"):
text, citations = await collect(ask(conversation_id, user.id, question))
return JSONResponse(completion_json(text + render_citations(citations)))
async def sse():
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
yield chunk(completion_id, {"role": "assistant", "content": ""})
citations = []
async for event in ask(conversation_id, user.id, question):
if event["type"] == "token":
yield chunk(completion_id, {"content": event["text"]})
elif event["type"] == "citation":
citations.append(event)
if citations:
yield chunk(completion_id, {"content": render_citations(citations)})
yield chunk(completion_id, {}, finish="stop")
yield "data: [DONE]\n\n"
return StreamingResponse(sse(), media_type="text/event-stream")
def render_citations(citations: list[dict]) -> str:
lines = [f"{c['marker']}. [{c['title']}]({c['url']})" for c in citations]
return "\n\n**Sources**\n\n" + "\n".join(lines)
Source: OpenAI API reference: Chat Completions streaming
(the chunk object and the [DONE] terminator); FastAPI: StreamingResponse.
Three design points follow from the protocol rather than from the code. The UIs resend the entire history on every
turn, while the core keeps its own memory per conversationId; the adapter therefore uses only the last user message and
ignores the rest, which keeps one source of truth for memory
(Conversational RAG & Memory). Neither product sends a
conversation identifier in the Chat Completions body, so the adapter derives a stable one from a header or, when none
is available, treats each request as a new conversation and loses follow-up context; check what your tool version can
forward before relying on a header. And the compatible endpoint should answer to a service credential from the
environment, not to anonymous callers. The same shape in Spring AI is a @RestController returning
Flux<ServerSentEvent<String>> built from ChatClient.stream()
(Spring AI: Streaming & Reactive); the LangChain side is covered in
LangChain: Streaming. The native /v1/ask contract this adapter wraps is defined in
Serving RAG as an API.
Not every request on this endpoint is a user question. Open WebUI also sends background task requests (chat title,
tag and follow-up suggestion generation, and query generation when its own search features are on) to the current
model unless a separate task model is set; against the adapter above each one would run a full retrieval and answer
under the user’s quota. Either set a task model that points at a cheap general-purpose model (TASK_MODEL and
TASK_MODEL_EXTERNAL, or the Interface settings in the admin UI) or turn the tasks off (ENABLE_TITLE_GENERATION,
ENABLE_TAGS_GENERATION, ENABLE_FOLLOW_UP_GENERATION).
assistant-ui
assistant-ui renders the conversation; a runtime connects it to a backend. The documentation lists runtimes for the Vercel AI SDK, LangGraph, LangChain, Google ADK, AG-UI, A2A and "custom servers". For DocsAssistant there are two natural routes.
Route 1: a ChatModelAdapter with useLocalRuntime. You write a small adapter that calls the core and yields
cumulative content. This works with any HTTP streaming format, including the NDJSON or SSE of /v1/ask.
"use client";
import {
AssistantRuntimeProvider,
useLocalRuntime,
type ChatModelAdapter,
} from "@assistant-ui/react";
import type { ReactNode } from "react";
const DocsAssistantAdapter: ChatModelAdapter = {
async *run({ messages, abortSignal }) {
const last = messages[messages.length - 1];
const question = last.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
const response = await fetch("/api/docs-assistant/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
signal: abortSignal, // lets the stop button cancel the request
});
if (!response.ok) throw new Error(`DocsAssistant returned ${response.status}`);
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
let text = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n"); // SSE events end with a blank line ("\n" for NDJSON)
buffer = events.pop()!; // keep the trailing partial event for the next read
for (const event of events) {
text += parseTokens(event); // your parsing of one complete token event
}
yield { content: [{ type: "text", text }] }; // cumulative, not a delta
}
},
};
export function DocsAssistantRuntimeProvider({ children }: { children: ReactNode }) {
const runtime = useLocalRuntime(DocsAssistantAdapter);
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}
Source: assistant-ui: LocalRuntime.
Two details come straight from the documentation: the adapter yields the full cumulative content on every iteration
rather than deltas, and abortSignal should be passed to every fetch so that cancelling in the UI cancels the request.
Two more come from the stream itself: a failed response is thrown so the UI shows an error instead of an empty answer,
and a read() chunk can end in the middle of an event, so the adapter buffers text and parses only complete events.
/api/docs-assistant/ask is a route in the same Next.js application that adds the user’s session and forwards to the
core, so no credential reaches the browser.
Route 2: the AI SDK runtime. If the back end already emits the AI SDK UI message stream (the format described in
Chat UI Protocols), assistant-ui can consume it directly and you
write no adapter. The documented way to point it at a non-default route is an AssistantChatTransport:
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
export function Chat() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/docs-assistant/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
);
}
Source: assistant-ui: AI SDK runtime (the api option of
AssistantChatTransport) and the repository README (the provider and
Thread layout).
The package names around the AI SDK integration have moved between releases: the README on 2026-10-01 installs
@assistant-ui/ai-sdk, while the runtime page imports from @assistant-ui/react-ai-sdk, and both exist on npm. Treat the
install instructions of the version you pin as authoritative. The Thread component is generated into your project by
the npx assistant-ui@latest init CLI rather than imported from the package, which is what "you own the components"
means in practice: you can change the markup, restyle it, and render DocsAssistant citations as cards instead of a
Markdown list (see Embedding a Web Chat Widget for the
citation rendering and accessibility concerns shared by any custom UI).
Licence and maintenance: the repository’s LICENSE file is the MIT licence, with the copyright held by AgentbaseAI Inc.
at the time of reading (https://github.com/assistant-ui/assistant-ui/blob/main/LICENSE). The project also sells a
hosted Assistant Cloud for thread persistence; it is optional, and persisting threads in your own database through the
core’s memory keeps conversation data in your infrastructure.
Chainlit
Chainlit is the shortest path for a Python team that wants a working UI without touching JavaScript. The DocsAssistant core is Python, so the handler can import it and stream straight from it:
import chainlit as cl
from docs_assistant.core import ask # async generator of token | citation | done events
@cl.on_message
async def on_message(message: cl.Message):
user = cl.user_session.get("user") # set by an auth callback, or None
conversation_id = cl.user_session.get("id") # one per Chainlit chat session
reply = cl.Message(content="")
citations = []
async for event in ask(conversation_id, user.identifier if user else "anonymous", message.content):
if event["type"] == "token":
await reply.stream_token(event["text"])
elif event["type"] == "citation":
citations.append(event)
if citations:
await reply.stream_token(
"\n\n**Sources**\n\n" + "\n".join(f"- [{c['title']}]({c['url']})" for c in citations)
)
await reply.send()
Run it with chainlit run app.py -w for development. The documentation’s own LangChain example has the same shape: an
astream() loop calling msg.stream_token(chunk) and a final msg.send(), with a
cl.LangchainCallbackHandler() in the run configuration to show intermediate steps in the UI.
Source: Chainlit: LangChain integration; quickstart pattern at the Chainlit repository.
If the core runs as a separate service, replace the import with an HTTP call to /v1/ask and forward the stream; the
handler stays the same. Chainlit apps are public by default. Authentication is opt-in and needs a signing secret in the
environment, generated with chainlit create-secret, plus one of three callbacks (password, OAuth or header):
export CHAINLIT_AUTH_SECRET="<output of chainlit create-secret>"
# OAuth providers are enabled by their own variables, for example:
export OAUTH_GITHUB_CLIENT_ID="<client-id>"
export OAUTH_GITHUB_CLIENT_SECRET="<client-secret>"
@cl.header_auth_callback
def header_auth(headers) -> cl.User | None:
# Behind an authenticating reverse proxy that sets a verified header
email = headers.get("x-forwarded-email")
return cl.User(identifier=email) if email else None
Source: Chainlit: Authentication overview. The exact environment variable
names per OAuth provider are listed in the Chainlit OAuth documentation; the generic OAUTH_CLIENT_ID form shown on the
overview page is for illustration. Whatever you choose, the documentation insists that every user get a unique
identifier, because that identifier is what separates one user’s data from another’s, and it is also the userId that
DocsAssistant uses for document permissions.
Licence and maintenance: the repository page states Apache-2.0 (https://github.com/Chainlit/chainlit/blob/main/LICENSE) and states that on 2025-05-01 the project moved to community maintenance, with the original team having stepped back from active development and the project run by a maintainers group. It still ships releases (2.12.0 on 2026-08-25). That is a real risk factor to weigh for a long-lived internal tool, not a reason to avoid it: pin the version, read the release notes before upgrading and keep the handler thin so the core, not the framework, holds the logic.
Open WebUI
Open WebUI connects to any OpenAI-compatible server. The documented environment variables are OPENAI_API_BASE_URL and
OPENAI_API_KEY (and OPENAI_API_BASE_URLS for several connections); the same connection can be added at run time in
the admin settings under Connections. A minimal Compose file with the adapter from above:
services:
open-webui:
image: ghcr.io/open-webui/open-webui:latest # pin a version tag in real deployments
ports:
- "3000:8080"
environment:
OPENAI_API_BASE_URL: "http://docs-assistant:8000/v1"
OPENAI_API_KEY: "${DOCS_ASSISTANT_SERVICE_KEY}" # supplied from the environment or a secret store
WEBUI_SECRET_KEY: "${OPEN_WEBUI_SECRET_KEY}"
ENABLE_SIGNUP: "false"
DEFAULT_USER_ROLE: "pending"
WEBUI_NAME: "Open WebUI" # see the branding note below before changing this
volumes:
- open-webui-data:/app/backend/data
depends_on:
- docs-assistant
docs-assistant:
build: ./docs-assistant
environment:
DOCS_ASSISTANT_SERVICE_KEY: "${DOCS_ASSISTANT_SERVICE_KEY}"
volumes:
open-webui-data:
Behaviour worth knowing before you rely on it. The documentation notes that if your server lacks a /models endpoint you
add model IDs to an allowlist by hand, which is why the adapter above serves GET /v1/models. Several of the settings
you will touch, including connections, are persistent configuration: with ENABLE_PERSISTENT_CONFIG at its default,
values saved in the admin UI take precedence over the environment on later starts, so a changed variable can appear to be
ignored. Authentication is built in (local accounts with roles, signup toggle, OAuth and OpenID Connect settings such as
OPENID_PROVIDER_URL and OAUTH_CLIENT_ID); roles and per-model access control decide who sees the DocsAssistant model.
Open WebUI’s own knowledge and document-upload RAG is configured through variables like VECTOR_DB and
RAG_EMBEDDING_MODEL; leave it unused for DocsAssistant, or restrict it, so retrieval stays behind the core. For the
same reason, set a separate task model or disable title, tag and follow-up generation (described with the adapter
above), so background tasks do not trigger extra DocsAssistant retrieval runs.
Licence: this is the tool whose licence needs the most care. According to the project’s licence page
(https://docs.openwebui.com/license/) and the LICENSE file (https://github.com/open-webui/open-webui/blob/main/LICENSE),
releases up to v0.6.5 are under the plain BSD-3-Clause licence, and from v0.6.6 (April 2025) the licence is BSD-3-Clause
with an additional clause that requires keeping the visible "Open WebUI" branding. The same pages list exceptions: a
deployment of up to 50 end users in any 30-day period, written permission from the copyright holder, or an enterprise
licence. The project itself says this is not an OSI-approved open-source licence. For an internal DocsAssistant with a few
dozen users the small-deployment exception may apply; for a public-facing deployment, or one you want to rebrand, get the
terms confirmed with the project (and with your legal team) before committing to it.
LibreChat
LibreChat takes its model connections from a librechat.yaml file; any OpenAI-compatible server is a custom
endpoint. The documented example uses ${VARIABLE} references that are resolved from the .env file, so the key never
sits in the YAML:
version: 1.3.13
cache: true
endpoints:
custom:
- name: "DocsAssistant"
apiKey: "${DOCS_ASSISTANT_SERVICE_KEY}"
baseURL: "http://docs-assistant:8000/v1"
models:
default: ["docs-assistant"]
fetch: true # read GET /v1/models from the adapter
titleConvo: true
titleModel: "current_model"
modelDisplayLabel: "DocsAssistant"
DOCS_ASSISTANT_SERVICE_KEY=<service-key>
ALLOW_REGISTRATION=false
JWT_SECRET=<random-32-bytes-or-more>
JWT_REFRESH_SECRET=<another-random-secret>
Source: LibreChat: custom endpoints and LibreChat: authentication.
titleConvo makes LibreChat ask the model to generate conversation titles, which means an extra request to the
endpoint per new conversation: with a RAG back end that is a wasted retrieval and a few extra tokens, so most
deployments set it to false or route titles to a small separate model. When running with Docker Compose, the YAML file
has to be mounted into the API container (the documentation uses a Compose override file for that); check the current
Docker guide for the mount path of your version.
Authentication options are broad: local email accounts, OAuth2 and OpenID Connect (OPENID_CLIENT_ID, OPENID_ISSUER,
OPENID_SESSION_SECRET), LDAP and SAML, with token lifetimes set through SESSION_EXPIRY and REFRESH_TOKEN_EXPIRY.
For retrieval over user-uploaded files LibreChat offers a separate RAG API service backed by PostgreSQL with pgvector;
as with Open WebUI, it is a second retrieval path that DocsAssistant’s governed retrieval does not control, so decide
deliberately whether to enable it. Single sign-on through OpenID Connect is the right place to connect it to the
identity provider used elsewhere (see OAuth Flows Overview).
Licence: the LICENSE file at https://github.com/danny-avila/LibreChat/blob/main/LICENSE is the MIT licence. The
project is active (the repository had commits on the day of writing); it versions through git tags.
Choosing
| Situation | Suggested choice |
|---|---|
You already ship a React or Next.js product and want chat inside it, with your login and your design system |
assistant-ui. Most work, most control, and no second user database. |
A Python team needs an internal prototype or tool in an afternoon, close to the core’s code |
Chainlit, accepting community maintenance. Keep the handler thin. |
Staff want a ChatGPT-style app with accounts, history and several models, DocsAssistant being one of them |
Open WebUI or LibreChat. Pick on licence fit (the Open WebUI branding clause), authentication needs (LibreChat’s broader SSO list) and which one your operators prefer to run. |
You need per-user document permissions enforced end to end |
A UI you control (assistant-ui or Chainlit with real auth callbacks), or a product configured to forward a verified per-user token to the adapter. Verify what your product version forwards; do not assume it. |
The UI must be customer-facing and branded |
assistant-ui or a custom widget. Open WebUI’s branding clause and LibreChat’s product look both argue against it. |
Whatever the choice, the integration surface stays small: one OpenAI-compatible endpoint for the products, one adapter or handler for the library and the framework. The channel-agnostic core is untouched, and the operational concerns of any chat surface (rate limits, observability, abuse) are the subject of Operating Chat Channels.
References
-
assistant-ui documentation: https://www.assistant-ui.com/docs and repository https://github.com/assistant-ui/assistant-ui
-
assistant-ui LocalRuntime: https://www.assistant-ui.com/docs/runtimes/custom/local-runtime
-
assistant-ui AI SDK runtime: https://www.assistant-ui.com/docs/runtimes/ai-sdk/v6-legacy
-
Chainlit documentation: https://docs.chainlit.io/ and repository https://github.com/Chainlit/chainlit
-
Chainlit authentication: https://docs.chainlit.io/authentication/overview
-
Chainlit LangChain integration: https://docs.chainlit.io/integrations/langchain
-
Open WebUI documentation: https://docs.openwebui.com/
-
Open WebUI licence: https://docs.openwebui.com/license/ and https://github.com/open-webui/open-webui/blob/main/LICENSE
-
Open WebUI environment variables: https://docs.openwebui.com/reference/env-configuration
-
LibreChat documentation: https://www.librechat.ai/docs
-
LibreChat custom endpoints: https://www.librechat.ai/docs/quick_start/custom_endpoints
-
LibreChat authentication: https://www.librechat.ai/docs/configuration/authentication
-
LibreChat RAG API: https://www.librechat.ai/docs/features/rag_api
-
OpenAI API reference, Chat Completions streaming: https://platform.openai.com/docs/api-reference/chat-streaming
-
FastAPI custom responses: https://fastapi.tiangolo.com/advanced/custom-response/