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.

flowchart LR subgraph UIs["Ready-made UIs"] AUI["assistant-ui
(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 (chainlit run), container-friendly; add a database for history

Docker image ghcr.io/open-webui/open-webui, with its own data volume

Docker Compose stack (app, MongoDB and further services; the RAG API is a separate container)

Talks to DocsAssistant via

A ChatModelAdapter you write, an AI SDK route, or other supported runtimes such as LangGraph and AG-UI

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 librechat.yaml (OpenAI-compatible)

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 LICENSE file of the repository)

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 LICENSE file of the repository)

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.

docs_assistant/openai_compat.py — illustrative Chat Completions adapter (FastAPI)
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.

components/DocsAssistantRuntime.tsx — LocalRuntime with a streaming adapter (illustrative)
"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>;
}

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:

components/Chat.tsx — AI SDK runtime pointed at the DocsAssistant stream route (illustrative)
"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:

app.py — Chainlit handler streaming the DocsAssistant core (illustrative)
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.

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):

Environment and callback for Chainlit authentication (illustrative)
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:

compose.yaml — Open WebUI in front of the DocsAssistant adapter (illustrative)
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:

librechat.yaml — DocsAssistant as a custom endpoint (illustrative)
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"
Entries in LibreChat’s .env (placeholders)
DOCS_ASSISTANT_SERVICE_KEY=<service-key>
ALLOW_REGISTRATION=false
JWT_SECRET=<random-32-bytes-or-more>
JWT_REFRESH_SECRET=<another-random-secret>

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.