An LLM agent that lives in QQ group chats, and remembers them.
It answers when it is asked, checks its facts with real tools, reads what people post (links, files, images, chat-log forwards), keeps a searchable long-term memory of each group, and can do actual work in a sandboxed Linux workspace. All of it is configured live from a web dashboard.
Features · Architecture · Quick start · Deployment · Development
| Answers when addressed | Triggers on an @mention, a reply to the bot, or its name in the text. Trigger rules (keywords, regex, scope, roles, cooldowns, priorities) are data, edited and tested in the dashboard. Other bots never trigger it: accounts tagged [bot], official QQ bots and an ignore list. |
| Checks before it answers | The chat agent searches the web, reads the sources, and looks up the group's own history before it writes. Replies are QQ merged-forward cards with sections and cited sources. The agent may also decide a message wasn't meant for it and stay silent. |
| Follow-ups keep the thread | Replying to one of its answers (chat or TL;DR) within an hour continues that answer's conversation: the model still has what it looked up and said, and only gets the chat since then plus the new question. The chat it sees marks who replies to whom. |
| TL;DR | Reply tldr / 太长不看 / 总结一下 to a message, link, file or forwarded chat log and get a summary of it. Asking again for the same target within a few minutes gets a reaction instead of a second summary. |
| Persona and prompts | Name, voice, reply style, quoting and status reactions per group; every prompt is a Jinja template you can override. |
| Operations by chat | Superusers can ask the ops agent in private chat to inspect or change configuration in natural language, and approve or reject held replies with 通过 / 拒绝 / 改. |
| Input | How |
|---|---|
| Links | read_url: fast HTTP extraction with a browser TLS fingerprint (curl_cffi) and site adapters (bilibili and YouTube videos with their subtitles as a timed transcript, GitHub repos, folders, files, issues, pull requests and profiles through the public API, Tangled code, Hacker News and Lobsters threads with all comments, Telegram, X, Statuspage incidents, Ubuntu CVEs, Hugging Face model and dataset cards, MSN articles, ...), then a real Chrome for JavaScript-only or blocked pages, then (optionally) the DeepSeek Fetch API for pages neither can read from your IP, such as many Chinese platforms. A link's #fragment starts the text at that section, and JSON API responses are compacted (no ids, API cross-links or signatures). Login walls, captchas, cookie-consent walls and anti-bot block pages come back as a clear error, not a hallucinated summary. |
| X | read_x reads a post with its full text, quoted post, poll, community note, conversation and images through FxTwitter, falling back to the official X API. search_x asks Grok (xAI Responses API with its x_search tool) to search X and summarize the posts with links; when no Grok key is configured or the call fails, it falls back to X News stories and recent posts from the official X API. Both are pay per use; their keys are stored as search providers. |
| Files | PDF, Office documents, text and code via markitdown and pypdfium2. Scanned PDFs are sent to vision models as page images. |
| Images | Vision models see the original; otherwise a vision route describes and transcribes it (OCR fallback). Animated GIF/WebP stickers become one sheet of up to 16 frames, sampled where the picture changes (every 0.1–0.3 s), because models only read an animation's first frame. Images from earlier in the chat can be opened by message id, and expired QQ image links are re-signed transparently. |
| Chat-log forwards | Expanded recursively, nested forwards included. |
| Cards, replies, mentions | Normalized into readable text with stable 名字(QQ号) labels. |
The worker turns the raw message stream into memory the agent can search:
- Topics: messages are segmented into topics with titles, summaries, keywords and participants. Topics close when a conversation goes idle; recalled messages trigger a recompute.
- Glossary: group slang and in-jokes are mined nightly and defined from real examples.
- Profiles: per-member interests, recurring themes and facts, built from closed topics. The
get_profiletool shows the ten highest-weighted facts. - Curation: an hourly job rates closed topics and profile facts 1–5 with the topic and profile routes, weights them by size, supporting messages and age, merges duplicate facts, keeps at most
memory.facts_per_useractive facts per member, and archives what falls belowmemory.topic_archive_below_weight/memory.fact_archive_below_importance. Archived items leave search, digests, profile updates and prompts but stay in the console, where they can be restored. - Daily digests: one summary of each day in a fixed plain-text format (overview, then numbered events with message counts and participants).
- Hybrid search: BM25 with the jieba tokenizer (ParadeDB
pg_search) plus pgvector embeddings, fused with RRF, over messages, topics, glossary and digests. - Stickers: custom stickers seen in chat are saved on first sight (QQ image links expire quickly), counted per group, and described, tagged and moderation-flagged by a vision model (
stickerroute). Flagged stickers stay enabled unlessmoderation.disable_unsafe_stickersis on. - Privacy: members can say
不要记我/记得我/忘记我(with an @mention) to opt out, opt back in, or delete what was stored about them.
With the optional sandbox, the chat model gets persistent Linux workspaces, one per topic. Each is its own gVisor pod, claimed from a warm pool.
shruns shell scripts;pyis a persistent IPython kernel with a scriptable headless Chromium.backgroundruns long commands, anddelegatehands a small multi-step task to an inner coding agent, which works quickly: the bot rewrites the request into a narrow task, and at the time limit (10 minutes by default) the agent is told to stop and summarize what it finished.delegatecan be switched off on its own (tools.sandbox_delegate). Both report back to the group when they finish.- Agents inside a workspace reach models through the bot's LLM relay, with a short-lived per-job token and budget instead of a provider key.
- Services started in a workspace can be published on a public link, on a domain reserved for untrusted content.
render_markdown, render_mermaid and render_html turn tables, diagrams, math and code into crisp images
(GitHub-style Markdown with Shiki and KaTeX, every Mermaid diagram type, sanitized inline HTML) via a warm
Chromium page pool, typically in well under a second.
- Moderation: an optional Qwen3Guard classifier checks requests and replies. Suspicious replies are held for a superuser's review instead of being sent.
- Guardrails in code: per-user and per-group concurrency limits with bounded queues, a daily token budget per group, usage limits per run, SSRF-guarded fetching, untrusted content fenced in prompts, and action tools only handed to a run once the speaker is allowed to use them.
- Dashboard: providers and model routes, groups and per-group settings, triggers (with a live tester), versioned prompts, stickers, search providers, memory browser, review queue, sandbox workspaces and a searchable, permanent history of background jobs with their full event logs, and invocation history with tool calls, token usage and latency. Every configuration change is audited.
- Observability: OpenTelemetry traces, metrics and logs, with invocations linked to their traces.
- Any model: OpenAI- and Anthropic-compatible providers, a model route per task (chat, tldr, vision, memory, moderation, embeddings, ...) with fallbacks; model capabilities come from models.dev.
flowchart LR
qq([QQ]) <--> onebot["OneBot v11 endpoint<br/>SnowLuma / NapCat"]
onebot -- reverse WebSocket --> bot
subgraph cluster[Kubernetes namespace]
bot["<b>bot</b><br/>NoneBot2 + FastAPI<br/>agents · dashboard · /api"]
worker["<b>worker</b><br/>taskiq<br/>memory pipelines"]
scheduler["<b>scheduler</b><br/>cron"]
pg[("PostgreSQL<br/>pg_search + pgvector")]
redis[("Redis")]
render["<b>render</b><br/>Chromium: Markdown/HTML to PNG"]
browser["<b>browser</b><br/>real Chrome for read_url"]
embed["embeddings<br/>llama.cpp"]
guard["moderation<br/>llama.cpp"]
sandbox["sandbox workspaces<br/>gVisor pods"]
end
scheduler --> redis --> worker
bot & worker --> pg
bot & worker --> redis
bot --> render
bot & worker --> browser
bot & worker --> embed
bot --> guard
bot <--> sandbox
bot & worker --> llm["LLM providers<br/>OpenAI / Anthropic format"]
admin([Admin browser]) -- SSO proxy --> bot
One image serves the bot, worker, scheduler and migration roles. The bot owns the single OneBot connection; heavy work runs in the worker, and anything the worker wants to say goes back through the bot.
What happens when someone @-mentions the bot
sequenceDiagram
autonumber
participant U as Group member
participant O as OneBot endpoint
participant B as Bot
participant A as Chat agent
participant T as Tools
U->>O: @bot question (+ link / image / quote)
O->>B: message event
B->>B: store message, route trigger, check rate limits and roles
B->>B: moderation (optional), build context: recent chat, attachments, workspace state
B->>A: run with the group's persona and tools
loop until it can answer
A->>T: web_search, read_url, search_history, view_image, sh, render_markdown, ...
T-->>A: results
end
A-->>B: structured reply (sections, sources) or silence
B->>B: output moderation (optional), hold for review if flagged
B->>O: merged-forward card (+ rendered images)
O->>U: reply
Agent tools
| Group | Tools |
|---|---|
| Web | web_search, read_url, search_x, read_x |
| Attachments | view_image, read_file, expand_forward |
| Group memory | search_history, get_messages_around, recent_chat, lookup_glossary, get_profile |
| Rendering | render_markdown, render_mermaid, render_html |
| Workspaces | open_workspace, sh, py, sandbox_put, reset_workspace |
| Background jobs | background, delegate, jobs, cancel_job |
| Actions (granted per run) | recall_message, ... |
| Layer | Choice |
|---|---|
| Runtime | Python 3.13, uv, NoneBot2 (FastAPI driver) + OneBot v11 adapter, Alconna |
| Agents | pydantic-ai (OpenAI and Anthropic model classes), structured outputs, usage limits |
| Storage | PostgreSQL 18 with ParadeDB (pg_search BM25 + jieba, pgvector), SQLAlchemy 2 async, Alembic |
| Background | taskiq + Redis |
| Dashboard | React 19, Vite, shadcn/ui, TanStack Query/Router, client generated from the OpenAPI schema |
| Render service | Node + TypeScript, Playwright Chromium, markdown-it, Shiki, KaTeX, Mermaid, DOMPurify |
| Browser service | Google Chrome (headed, Xvfb), patchright, nodriver, curl_cffi for the plain-HTTP tier |
| Sandbox | kubernetes-sigs/agent-sandbox, gVisor, sandboxd (REST + gRPC), IPython |
| Local inference | llama.cpp: Qwen3-Embedding-0.6B, Qwen3Guard-Gen-0.6B |
| Observability | OpenTelemetry (OTLP/HTTP), loguru JSON logs |
| Quality | ruff, basedpyright, pytest against real Postgres, Redis and sandboxd |
You need uv, Node 22+, Docker (or any OCI runtime) and a OneBot v11 endpoint logged in to a QQ account.
# 1. Postgres with pg_search + pgvector, and Redis
docker run -d --name ygg-pg -p 5432:5432 \
-e POSTGRES_USER=ygg -e POSTGRES_PASSWORD=ygg -e POSTGRES_DB=ygg paradedb/paradedb:0.26.0-pg18
docker run -d --name ygg-redis -p 6379:6379 redis:8
# 2. Configuration
cp .env.example .env # then set YGG_SECRET_KEY (command inside) and ONEBOT_ACCESS_TOKEN
# 3. Dependencies and schema
uv sync
uv run alembic upgrade head
# 4. Run the bot, the worker and the scheduler
uv run python -m yggdrasil
uv run taskiq worker yggdrasil.worker.broker:broker yggdrasil.worker.tasks
uv run taskiq scheduler yggdrasil.worker.broker:scheduler yggdrasil.worker.tasksPoint the OneBot endpoint's reverse WebSocket client at ws://127.0.0.1:8192/onebot/v11/ws with the same
access token, array message format and self messages off (.env.example runs the bot on port 8192, where the
dashboard's dev server expects the API).
Then open the dashboard. Either build it once (cd web && npm ci && npm run build; the bot serves web/dist
at http://127.0.0.1:8192/), or run the dev server:
cd web && npm install && npm run dev # Vite proxies /api to 127.0.0.1:8192With YGG_ENV=dev and YGG_DEV_ADMIN_EMAIL set, the dashboard needs no login. In it:
- Providers: add an LLM provider (OpenAI- or Anthropic-format base URL and key) and an embedding provider.
- Models: map the task routes (
chat,tldr,vision,memory_*, ...) and activate an embedding space. - Groups: enable a group. @-mention the bot there.
Optional: render service and API-only mode
# Render service (render_* tools)
docker build -t ygg-render render
docker run -d --name ygg-render -p 8580:8080 --tmpfs /tmp --shm-size 1g ygg-render
# Browser service (read_url's real-Chrome tier); built from the repository root
docker build -t ygg-browser -f browser/Dockerfile .
docker run -d --name ygg-browser -p 8680:8080 --tmpfs /tmp --shm-size 1g ygg-browser
# then set YGG_RENDER_URL=http://127.0.0.1:8580 and YGG_BROWSER_URL=http://127.0.0.1:8680 in .env
# Instead of the bot: the dashboard API alone, without a OneBot connection
uv run uvicorn yggdrasil.admin.standalone:app --port 8192Production runs on Kubernetes. deploy/README.md walks through a complete install, and
deploy/example/ contains the manifests: Postgres (CloudNativePG), Redis, the migration job,
bot, worker, scheduler, SnowLuma, render, embeddings and moderation, network policies, an SSO-protected
dashboard route, and an optional sandbox component.
uv sync --locked
uv run ruff format --check . && uv run ruff check .
uv run basedpyright
uv run alembic upgrade head && YGG_TEST_DB=1 uv run pytest -q
(cd web && npx tsc -b --noEmit)
(cd render && npx tsc --noEmit -p .)Database tests run only with YGG_TEST_DB=1, against the Postgres that POSTGRES_* names; use a disposable
database, since they delete rows. Without the flag they are skipped, and any other test that reaches the database
fails. Redis is faked in-process; YGG_SECRET_KEY must be set. Bot-side sandbox tests use an in-memory sandboxd. The
tests of the sandbox image itself (in-sandbox helpers, backend conformance) need a sandboxd container
(sandbox/Dockerfile, target sandboxd-test) and YGG_TEST_SANDBOXD_REST / _GRPC / _TOKEN; they are skipped
otherwise. CI (.github/workflows/ci.yaml) runs the image tests in the sandbox-v* release job and everything else
on every push and pull request, including a web job that regenerates and builds the dashboard client.
After changing the admin API, regenerate the dashboard client:
uv run python -m yggdrasil.admin.dump_openapi > web/openapi.json && (cd web && npm run gen)yggdrasil/
bot/ NoneBot plugin: events, command routing
pipeline/ one triggered run: moderation, agent, delivery, bookkeeping
agent/ chat / tldr agents, context building, tools
memory/ topics, glossary, profiles, digests, hybrid search
ingest/ message normalization and storage
stickers/ sticker collection and vision tagging
tools/ web reading and search, documents, rendering client
sandbox/ workspace manager, background jobs, inner agent
relay/ LLM relay for agents inside workspaces
moderation/ guard model, review queue
ops/ natural-language admin agent
admin/ dashboard API
worker/ taskiq broker and scheduled tasks
prompts/ Jinja prompt templates
migration/ Alembic migrations
web/ dashboard (React)
render/ render service (Node + Chromium)
sandbox/ workspace image (sandboxd, Python, Chromium, helpers)
deploy/ deployment guide and example manifests
Each image is released by its own git tag; one commit may carry several:
| Tag | Image |
|---|---|
vX.Y.Z |
yggdrasil-bot:vX.Y.Z (bot, worker, scheduler, migration) |
sandbox-vX.Y.Z |
yggdrasil-sandbox:vX.Y.Z |
render-vX.Y.Z |
yggdrasil-render:vX.Y.Z |
browser-vX.Y.Z |
yggdrasil-browser:vX.Y.Z |
YggdrasilBot is built on these open-source projects. Thanks to everyone who writes and maintains them.
| Area | Projects |
|---|---|
| QQ access | SnowLuma, NapCat (OneBot v11 endpoints) |
| Bot framework | NoneBot2, nonebot-plugin-alconna |
| Agents | pydantic-ai |
| Storage and search | PostgreSQL, ParadeDB, pgvector, SQLAlchemy, Alembic |
| Background jobs | taskiq, Redis |
| Local models | llama.cpp, Qwen3-Embedding, Qwen3Guard |
| Reading the web | curl_cffi, trafilatura, patchright, nodriver |
| Documents | markitdown, pypdfium2 |
| Rendering | Playwright, markdown-it, Shiki, KaTeX, Mermaid, DOMPurify |
| Sandboxes | agent-sandbox, gVisor |
| Dashboard | React, Vite, shadcn/ui, TanStack Query and Router, Hey API |
| Tooling | uv, ruff, basedpyright, loguru |
GNU Affero General Public License v3.0 or later. If you run a modified version as a network service, you must offer its users the corresponding source.