Basjoo AI Agent
Self-hosted AI customer-support platform — per-KB RAG on Qdrant, 13 LLM providers, SSE streaming, embeddable chat widget. FastAPI + Vite + SQLite, all in Docker Compose.
Basjoo is an open-source ai agent by haoyiyin, written mainly in Python, with 164 GitHub stars and the MIT license. Its code was last updated Oct 31, 2026.
Basjoo is a self-hosted AI customer-support platform: it turns a website plus a set of documents into a support agent you can embed anywhere, and gives you a console to run it — analytics, a session transcript wall, human takeover, a Q&A index and lead capture, all in one box you own.
Install
curl -fsSL https://raw.githubusercontent.com/haoyiyin/basjoo/main/install-deploy.sh | sudo sh
One command on a fresh Ubuntu / Debian host: installs Docker if missing, clones the repo, starts the
prod profile, waits for the health checks and prints the console URL. The first account you register
becomes the workspace super administrator. Already-cloned checkout? sudo sh install-deploy.sh in it.
Other distributions work too — if Docker Engine with the Compose plugin, git and python3 are already
installed, the script skips package setup and deploys; otherwise it stops and tells you what to install.
Prefer to drive Compose yourself? See Docker Compose by hand.
- FastAPI backend (
backend/) — agents, knowledge bases, RAG retrieval, SSE chat, curated Q&A, leads, analytics, webhooks, auth, schedulers - Vite SPA console (
frontend-nextjs/— the directory name is left over from the former Next.js app) - Embeddable chat widget (
widget/) — one<script>tag, talks to the backend over HTTP + SSE - Retrieval stack — Qdrant for vectors, SQLite for application data, Redis for rate limiting, and a
crawl4ai fetcher container (
basjoo-crawler) that renders pages in a headless browser
Everything runs under Docker Compose. No external SaaS is required: LLM inference is delegated to whichever provider you configure, and a fresh install ships a local embedding container, so the knowledge base works before you own any API key. No GPU is needed.
Features
- Knowledge bases are first-class. A KB exists on its own — create, reset, delete and bind it independently of any agent, and a KB can outlive every agent that used it. Sources (URLs, uploaded files) belong to the KB, not to the agent.
- Two ingestion paths per KB. Crawl one page, crawl a whole site (depth + page cap), or upload files
(
txt,md,html,pdf,docx,xlsx; up to 5 files and 20 MB per file per request). - Per-KB embeddings.
local(bundled TEI container),jina,siliconflow, or anycustomOpenAI-compatible endpoint. The vector width is measured with a real embedding call when the KB is created, so the collection and the provider can never silently disagree. - Thirteen LLM providers through one agent config: OpenAI-compatible, OpenAI native, Anthropic, Google Gemini, xAI, DeepSeek, OpenRouter, z.ai, Volcengine, Moonshot, Alibaba Bailian, Tencent Cloud and SiliconFlow.
- Streaming chat over SSE with source citations, a first-token keepalive, per-visitor rate limits and a
configurable restricted reply when a provider fails. No provider key at all? Curate Q&A pairs with
keywords and flip
fallback_mode(defaultoff) tokeyword: the bot answers matched questions with no model and no API key, markedmode: "scripted"; a miss falls back to the restricted reply. - A curated Q&A index per KB. Pairs are embedded on their own and matched before any retrieved passage, so a correction takes effect without a reindex. Bulk import, a "try a message" tester that shows the score and the matched keywords, per-pair hit counts, and a one-click "revise this answer" on any assistant turn that writes a new pair straight onto the bound KB.
- Prompt layers you can edit. Persona and model in the AI settings pane,
fixed_factsfor the facts the bot must never contradict, ordered procedures for multi-step flows — each reaching the prompt in its own position, never as a retraining of the model. - Analytics that say what to fix. Sessions, messages, visitors, leads, handoffs, unanswered questions, thumbs-down and the positive-feedback rate over 24 h / 7 d / 30 d / 90 d, each with a delta against the previous window and CSV export. Conversations are classified when they end (resolved, abandoned, escalated, could not answer), and the unanswered queue groups the same question so you can turn it into a Q&A pair.
- Lead capture, with an inbox.
POST /api/v1/leadsis a public, origin-checked route that writes one lead per session (idempotent, so a refresh edits instead of duplicating) with name, email, phone, message, page URL and acontactedflag; aprechatblock on a chat request lands the same way. The console's 留资线索/leadsscreen lists them with an uncontacted count, a source filter and a contacted toggle, and links back to the originating session when there is one. - Attachments, macros and a visitor-side close. A visitor can attach a screenshot or a document (images,
txt/md/html/pdf/docx/xlsx, 5 files and 5 MB per file per conversation); the parsed text reaches the prompt as untrusted material below the retrieved passages, never as instructions, and never enters the knowledge base. Operators save reply macros per workspace and drop them into the composer at the caret, with{{visitor.name}}-style variables substituted client-side. The widget can end its own chat, which the outcome classifier reads as a resolved conversation, and 👍/👎 on any model answer is stored on the turn and rolled up in analytics. - Human takeover. Operators watch live sessions in the console and jump in; the widget polls for the human reply and hands the conversation over. Reply without takeover, release back to the bot, resolve, or reopen a resolved session. Business hours decide when the bot is off duty, and the away message is shown but never written into the transcript.
- A widget that respects its host. Per-agent origin whitelist, three display modes (floating bubble,
inline container, iframe), path exclusions, a traffic percentage that is decided once per visitor, entry
cards and starters,
localStoragevisitor/session, locale-aware copy (English / Chinese), 90-second stream timeout with one retry, and a signed visitor identity (window.Basjoo.identify()) for hosts that know who is logged in. Optional pre-chat form (name + e-mail,off/optional/required), an AI disclosure line rendered before the first send, end-of-chat and new-chat actions in the header, and a visitor menu that stays disabled until the conversation has really started. - Outbound webhooks. Seven events (
session.created,session.closed,message.created,message.unanswered,handoff.requested,lead.created,document.indexed), queued in the same transaction as the change, signed withX-Basjoo-Signature: sha256=<hmac>over the exact bytes sent, and retried with backoff — a slow receiver never blocks a chat reply. - Bilingual console (English / 简体中文) on a warm, single-accent design system that holds up at 375 px.
Screenshots
Agents
One table for every agent: its knowledge base, last update, status and actions.

Knowledge base
Sources live on the KB: crawl controls, the URL list with fetch and index status, the uploaded files with their parsed chunk counts, and the curated Q&A index that answers ahead of retrieval.

Playground
Provider, model and retrieval settings on the left; a live conversation on the right, through the same streaming path the widget uses. The Playground works without a model: a keyless agent is served by the curated keyword path, so you can test it before configuring any provider key.

Session console
Every visitor session with its transcript, and the actions a bot should not take alone: reply (with saved macros inserted at the caret), takeover, release, resolve, reopen, email the transcript, delete the visitor's data. The header carries the pre-chat name and e-mail when the visitor supplied them, and every model answer has a thumbs control whose vote lands on the turn it answered.

Dashboard
Eight tiles with a delta against the previous window, the outcome split for finished conversations, and the questions that went unanswered — each row links back to the message that produced it.

Agent settings and embed code
Five groups in prompt order — the AI, its material, handoff, install, outbound webhooks — with the display mode, entry cards, starters and the embed snippet to paste into your site.

Embedded widget

Quick start
Docker Compose by hand
git clone https://github.com/haoyiyin/basjoo.git
cd basjoo
cp .env.example .env # optional — every value has a working default
docker compose --profile prod up -d # nginx on :80 (:443 when ./ssl holds a cert)
# or, with hot reload:
docker compose --profile dev up --watch # frontend :3000, backend :8000
Then open the console and register the first admin:
- prod:
http://localhost/ - dev:
http://localhost:3000/
Add a provider key under Playground → AI Settings (or set DEEPSEEK_API_KEY for the default agent) and
the bundled example agent becomes usable — or leave the key empty and curate keyword Q&A pairs to try the
scripted keyword path instead.
Default ports
| Service | dev | prod |
|---|---|---|
| Admin console | localhost:3000 | localhost:80 (nginx) |
| Backend API | localhost:8000 | through nginx |
| Qdrant | 127.0.0.1:6333 | internal only |
| Redis | 127.0.0.1:6379 | internal only |
System requirements
LLM inference is always an external API call. Embeddings can be external too, but the default stack runs the
bundled local-embed container (CPU only, ~1.2 GB resident). The fetcher container renders pages in a
headless Chromium: ≈380 MB resident after the first page, ≈425 MB with 8 pages in flight (the browser is
shared across requests, so concurrency adds little). Its image is ≈2.1 GB.
| Minimum | Recommended | |
|---|---|---|
| CPU | 2 vCPU | 2–4 vCPU |
| RAM | 4 GB | 8 GB |
| Disk | 25 GB | 50 GB |
| OS | Ubuntu 22.04+ / Debian 11+ | Ubuntu 22.04+ / Debian 12+ |
| Docker | 20.10+ with the Compose plugin | latest |
These figures are measured on our reference host, not enforced anywhere: install-deploy.sh only checks
that the distro is Ubuntu- or Debian-derived, and no compose service declares a memory limit. The RAM figure
already covers the embedding container. Its model
(ibm-granite/granite-embedding-97m-multilingual-r2, ~400 MB) is downloaded once into the
local-embed-cache volume; the stack stays usable during the download and the example agent is seeded on
the next login. To use a hosted embedding API instead, create the KB with the jina / siliconflow /
custom provider and stop the local-embed service.
How it works
browser ──▶ widget / admin SPA
│ HTTP + SSE
▼
nginx (prod) ──▶ FastAPI backend ──▶ Qdrant (vectors, one collection per KB)
│ └──▶ SQLite (agents, KBs, documents, sessions, leads)
├──────────▶ Redis (rate limits)
├──────────▶ basjoo-crawler (crawl4ai, headless Chromium)
├──────────▶ local-embed (TEI, CPU embeddings)
└──────────▶ LLM provider (OpenAI-compatible HTTP)
Knowledge bases
KnowledgeBase → KbDocument → KbChunk, scoped by workspace. One Qdrant collection per KB (kb_<12 hex>,
cosine distance), queried with a kb_id payload filter. Chunking defaults to 512 / 64 with embeddings upserted in
batches of ≤100; retrieval takes top_k × 2 candidates and post-filters by score. The effective similarity
threshold is the request argument, else the agent's similarity_threshold, else the service default.
Binding is the only link between an agent and a KB: PUT /api/v1/agents/{agent_id}/knowledge-base. Because
agents.kb_id is UNIQUE, at most one agent reads a given KB, and deleting an agent only clears the
pointer — the KB, its documents, chunks and URL sources survive.
Chat
POST /api/v1/chat/stream runs in two phases on purpose: a short database phase (resolve agent → origin
whitelist → session → rate limit → load history → match curated Q&A → retrieve context), then a streaming
phase that holds no database connection while the provider generates. The event names are sources,
content, thinking_done, done and error. A missing provider key is an explicit 400 API_KEY_MISSING,
never a silent mock answer — unless the agent opts into the scripted keyword path
(fallback_mode="keyword"), where a keyless agent answers matched questions with no model call and a miss
falls back to the restricted reply. Session ids are enumerable, so the public endpoints require the
visitor's own visitor_id and answer 404 Session not found for anyone else.
One gate decides whether the bot speaks: services/session_state.answer_gate requires status="active",
mode="ai" and the agent's business hours to be open. Anything else still stores the visitor's message
and answers nothing, reporting why in done.no_answer_reason (taken_over, resolved, closed, paused,
human or after_hours). after_hours is the one case that leaves mode at ai and ships the operator's
away_message as the visible reply — it is never written to the transcript, so the history does not claim a
person wrote it. An agent with no business hours (or an unparsable config) is always online.
Every assistant turn carries a confidence: the best retrieval score the prompt was built from, or null
when nothing was retrieved. An answer that matches no passage — or that equals a fallback sentence — is
flagged is_unanswered, which is what the dashboard's triage queue and the "add to Q&A" button read.
A visitor can attach files to a turn. POST /api/v1/chat/attachments stores the bytes on disk and the
metadata in chat_attachments (5 files and 5 MB per file per conversation; images are stored only, documents
are parsed to text), and the next turn names them in attachment_ids. The parsed text enters the prompt as a
labelled untrusted block below the retrieved passages and above the visitor's own words, capped at 10000
characters. An attachment is never a KbDocument, so it cannot end up in the knowledge base.
Thumbs write to chat_messages.rating on the visitor turn an answer replied to, so a vote stays in the
transcript's context and analytics counts answers rather than votes. A visitor ending the chat themselves
(POST /api/v1/chat/session:end) sets status="closed" with closed_by="visitor", which the outcome
classifier reads as a resolved conversation — an idle close leaves closed_by NULL and keeps its own bucket,
and the operator's reopen still works on either.
Widget
widget/src/BasjooWidget.tsx bootstraps from <script src="…/sdk.js?agent_id=agt_…"> (it also accepts
agentId and api_base), auto-detects the API base from its own script URL, fetches
/api/v1/config:public for the title, colour, greeting and install options, keeps the visitor and session in
localStorage, streams from /api/v1/chat/stream, and polls /api/v1/chat/messages (visitor id +
after_id, assistant role only) every 3 s once the first reply lands — that is what surfaces human-takeover
messages.
<script src="https://your-basjoo-host/sdk.js?agent_id=agt_xxxxxxxxxxxx" async></script>
The console hands out /api/v1/agents/{id}/embed.js instead, which bakes one agent's install config
(display mode, excluded paths, traffic percentage, cards, starters, prechat_mode, ai_disclosure) into a
per-agent loader. A host that already knows who is visiting can call window.Basjoo.identify({ email, hash })
with the HMAC the server issued, and the widget uses that signed identity instead of a random one.
The same config:public payload decides the pre-chat form (off / optional / required) and the AI
disclosure line, both inert when unset. A three-dots menu beside the human-handoff button offers New chat
(clears the stored session, keeps the visitor) and End chat (calls session:end, then clears it), and both
stay disabled until the conversation has at least one real turn.
Configuration
Settings come from environment variables or .env (pydantic-settings). Every value below has a working
default; docker-compose.yml sets the ones that differ between the dev and prod profiles.
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL | sqlite:///./data/basjoo.db | application data (SQLite only — there is no PostgreSQL path) |
REDIS_URL | redis://redis:6379/0 | rate limiting |
QDRANT_URL | http://localhost:6333 | vector store |
QDRANT_API_KEY | unset | key for a Qdrant that requires auth |
SCRAPLING_SERVICE_URL | http://scrapling-service:8001 | fetcher service used by URL ingestion |
SECRET_KEY / SECRET_KEY_FILE | generated → /app/data/.secret_key | JWT signing |
ENCRYPTION_KEY / ENCRYPTION_KEY_FILE | generated → /app/data/.encryption_key | encrypts provider keys, webhook secrets and identity secrets at rest |
DEFAULT_AGENT_ID | generated → /app/data/.agent_id | pins the default widget agent identity |
PUBLIC_BASE_URL | unset | public origin used to build embed snippets and /config:public; unset means trust X-Forwarded-Host, then Host |
ALLOWED_ORIGINS | * in code, * in dev, empty in prod | CORS allowlist; empty means no cross-origin caller |
CORS_ALLOW_NULL_ORIGIN | false | accept Origin: null sandboxed iframes |
TRUSTED_PROXY_IPS | empty, 172.16.0.0/12 in compose | peers whose X-Forwarded-For / X-Real-IP are believed; empty keys every limiter on the socket peer |
SSRF_PROTECTION_ENABLED | false | block private/link-local resolutions of user-supplied URLs |
LOCAL_EMBEDDING_BASE_URL | http://local-embed:80/v1 | endpoint used by local knowledge bases |
LOCAL_EMBEDDING_MODEL | ibm-granite/granite-embedding-97m-multilingual-r2 | model served there |
RATE_LIMIT_PER_MINUTE / RATE_LIMIT_BURST_SIZE | 1000 / 200 | global limiter |
LOGIN_RATE_LIMIT_MAX_ATTEMPTS / LOGIN_RATE_LIMIT_WINDOW_SECONDS | 5 / 300 | failed-login limiter, per IP and per account |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / SMTP_FROM / SMTP_USE_TLS | empty / 587 / … / true | transcript email; unset host means the route answers 503 instead of failing a send |
REQUIRE_SECRET_KEY | unset, true in the prod compose service | refuse to boot with a generated secret (checked by the container entrypoint, not by the app) |
SERVER_DOMAIN | unset | nginx canonical host; anything else gets 444 |
LOG_LEVEL | INFO | |
DEBIAN_MIRROR | http://deb.debian.org | build-time apt mirror for the backend and crawler images; change it when a proxy mangles the default |
SSRF_PROTECTION_ENABLED is opt-in because a fake-ip / split-horizon resolver (sing-box, clash) maps public
hosts into private ranges and would reject every fetch. Scheme, credential, empty-host, localhost and
direct-IP-literal checks always run; the crawler service honours the same switch.
DEFAULT_AGENT_ID only pins the id — it does not recreate a deleted agent row. Restore the database volume,
or set CREATE_DEFAULT_AGENT_ON_BOOTSTRAP=true for one boot.
Development
# backend
cd backend && python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt && python3 main.py # :8000, /health
# admin console
cd frontend-nextjs && npm install && npm run dev # :3000, proxies /api to :8000
# widget
cd widget && npm install && npm run dev # bundle + example host
Verification before a PR:
cd frontend-nextjs && npm run build && npm run typecheck && npm run test # vitest
cd widget && npm run build && npm run test
cd backend && pytest
npm run sync-widget # rebuild the widget, copy it to backend/static/sdk.js
npm run test:e2e # Playwright smoke suite against the dev profile
npm run sync-widget matters: widget/dist/basjoo-widget.min.js and backend/static/sdk.js must stay
byte-identical, and sdk.js is never hand-edited.
API surface
The console talks to /api/admin (login, register, account, users) and /api/v1. The routes that matter
most:
GET /health,GET /api/v1/config:public,GET /api/v1/agents/{id}/embed.jsGET|POST /api/v1/agents,POST /api/v1/agents/{id}:pause|:resume,DELETE /api/v1/agents/{id}GET|PUT /api/v1/agent,GET /api/v1/agent:kb-statusGET|POST /api/v1/knowledge-bases,GET|PATCH|DELETE /api/v1/knowledge-bases/{kb_id},POST /api/v1/knowledge-bases/{kb_id}/resetPUT /api/v1/agents/{agent_id}/knowledge-base— the only way to bind a KBPOST /api/v1/knowledge-bases/{kb_id}/urls:create|:refetch|:discover|:crawl_site|:cancel,…/files:upload,…/index:rebuildGET /api/v1/knowledge-bases/{kb_id}/sources:summary,…/index:statusGET|POST /api/v1/knowledge-bases/{kb_id}/qa-items,PUT|DELETE …/qa-items/{qa_id},POST …/qa-items:match,POST …/qa-items:importGET|POST|PUT|DELETE /api/v1/agents/{agent_id}/procedures,PUT /api/v1/agents/{agent_id}/fixed-factsPOST /api/v1/chat/stream,GET /api/v1/chat/messages,POST /api/v1/chat/escalate|release|feedback,POST /api/v1/chat/session:endPOST /api/v1/chat/attachments(multipart, one file per request),GET /api/v1/chat/attachments/{id}GET|POST /api/v1/admin/macros,PATCH|DELETE /api/v1/admin/macros/{id}POST /api/v1/leads,GET /api/v1/admin/leads,PATCH|GET /api/v1/admin/leads/{id}GET /api/v1/admin/analytics,GET /api/v1/admin/stats/overview,GET /api/v1/admin/stats/export.csvGET /api/v1/admin/sessions,POST /api/v1/admin/sessions/sendPOST /api/v1/admin/sessions/{id}/takeover|release|reply|resolve|reopen|email-transcriptGET /api/v1/admin/sessions/unanswered,POST …/unanswered/{message_id}:dismiss,POST /api/v1/admin/sessions/{id}/revise-answer,POST /api/v1/admin/qa/from-message/{message_id}DELETE /api/v1/admin/visitors/{visitor_id}— one visitor's sessions, messages, leads and feedback
The agent-scoped /api/v1/urls:*, /api/v1/files:* and /api/v1/index:* routes still resolve, but they are
one-release aliases that look the KB up through agents.kb_id and answer 409 when the agent is unbound.
New integrations should use the KB-scoped paths. The full schema is served at /openapi.json.
Deployment notes
install-deploy.shis the supported path on Ubuntu/Debian;deploy.shis what it calls.docker-compose.ymlis the single Compose entrypoint, withdevandprodprofiles and a pinned project name so a second checkout does not create a second, empty set of volumes.nginxlistens on:8080inside the container (published as:80) and enables:443only when readablecert.pem/key.pemexist under./ssl.client_max_body_sizeis 12 MB in the generated vhosts (50 MB in the baked fallback config used when neitherSERVER_DOMAINnor certs are set), so oversized uploads get a JSON error instead of an nginx HTML page.- Persistent volumes:
backend-data→/app/data(SQLite database, secret keys, uploaded KB files),qdrant-data,redis-data,local-embed-cache.docker compose down -vdeletes all four. - Existing embeds keep working as long as
backend-datasurvives: the agent id is persisted in/app/data/.agent_id, andDEFAULT_AGENT_IDpins a known id when you migrate hosts. Back up/app/data/basjoo.dband/app/data/.agent_idbefore moving. - Set
PUBLIC_BASE_URLwhen the console is not reached on the same host visitors use, otherwise the embed snippet falls back to the request's ownHost. - Registering a second admin requires the super-admin token (
POST /api/admin/users); self-registration is first-admin only, and the console has no user-management screen yet.
Known limitations
- Single-node by design: SQLite, one backend process, in-process schedulers. There is no Postgres or pgvector code path to switch on.
- One agent per knowledge base (
agents.kb_idis unique), so a KB is not shared between agents. - Roles (
super_admin,admin,support) are enforced by the API; the console exposes account settings and per-agent membership, not full admin CRUD. - Parsing covers
txt,md,html,pdf,docx,xlsx. There is no OCR for scanned PDFs. - Workspace quotas cap the agent count, the curated-pair count and the total text size; the URL and file counters report a fixed 500 the services never enforce, and message volume is limited per minute per visitor, not per day.
- Webhooks are best-effort: queued and retried with backoff, but a permanently failing receiver shows up in the backend log, not as a console alert.
- Transcript email needs SMTP configured; without it the route answers
503and the button reports it. Agent.last_error_code/last_error_message/last_error_atare schema fields with no writer and no UI yet; failures surface as the restricted reply and in the backend log, not as a console alert.
Project layout
| Path | What lives there |
|---|---|
backend/ | FastAPI app: api/ routers, services/ logic, models.py, tests/ |
frontend-nextjs/ | Vite SPA console (src/views, src/components, src/index.css design tokens) |
widget/ | embeddable widget source, esbuild bundles, example host page |
scrapling-service/ | crawler microservice (crawl4ai + curl_cffi/readability fallback), container basjoo-crawler |
nginx/ | reverse proxy used by the prod profile |
tests/e2e/ | Playwright specs (smoke, prod-like, widget cross-origin) |
scripts/ | sync-widget.sh, prod_stability_check.sh |
resource/screenshots/ | the images used by these READMEs |
AGENTS.md documents the architecture contracts in depth (KB scoping, the SSE watchdog, the design tokens);
read it before changing code in those areas.
License
MIT.
Acknowledgements
- Qdrant — vector search behind the per-KB collections.
- crawl4ai — headless rendering in the fetcher service.
- Scrapling — the
curl_cffi/ readability fallback extractor still shipped inscrapling-service/. - Text Embeddings Inference — the bundled CPU embedding server.
- FastAPI and Vite — backend and console toolchains.
Star history
Basjoo alternatives
View more →Similar ai agents in Database Management, ranked by GitHub stars. Compare licence, recent activity and scan status before you choose.
| Name | Stars | Licence | Last updated | Scan |
|---|---|---|---|---|
| Basjoo (this page) haoyiyin | 164 | MIT | Oct 31, 2026 | Scanned, no flagged issues |
| Graphify Graphify-Labs | 140k | Apache-2.0 | Nov 1, 2026 | Scanned, no flagged issues |
| Claude Mem thedotmack | 108k | Apache-2.0 | Nov 1, 2026 | Scanned, no flagged issues |
| Mem0 mem0ai | 68k | Apache-2.0 | Oct 30, 2026 | Scanned, no flagged issues |
| Hindsight vectorize-io | 57k | MIT | Nov 1, 2026 | Scanned, no flagged issues |
| Dokploy Dokploy | 38k | None found | Oct 31, 2026 | Not scanned yet |