A Discord bot backed by a local LLM. You give it a persona; it provides the shell.
Husk is a self-hosted Discord bot that chats through a local Ollama model, searches the web via SearXNG, and — unlike most chat bots — remembers across conversations. Cold threads are distilled into durable, searchable long-term memory and relevant past context is recalled into new threads. Everything runs on your own infrastructure; no third-party API.
The bot itself is persona-agnostic — its identity is entirely the PERSONA you configure.
- Threaded conversations. @mention the bot anywhere; it opens a thread for the exchange and tracks who said what.
- Two-tier memory.
- Hot: per-thread history in Redis (24h+ window).
- Long-term: context-forge (local SQLite + FTS5 BM25). Threads are distilled into a summary + facts and recalled into future threads, scoped to the server. Secrets are scrubbed before anything is stored, and retrieved memory is injected as clearly-labeled reference data, never as instructions.
/remember. Use/rememberin a thread to distill it to long-term memory immediately and archive it.- Web search (optional). When a SearXNG instance is configured, the model invokes a search tool on its own when a query needs current information. Omit it and the bot runs fine without web search.
- Configurable persona. The system prompt is the
PERSONAenv var — set it to whatever character or assistant you want.
- At each turn: the bot queries long-term memory (server-wide) for context relevant to the message and injects the hits as a labeled reference block.
- When a thread goes cold: it's distilled automatically — a background sweep ~2h after the last message, with the thread's auto-archive as a backstop — or on demand via
!remember. A dedup marker ensures a thread is only distilled once. - Distillation reuses your chat model. It points at the same Ollama host's OpenAI-compatible
/v1endpoint, so it needs no extra infrastructure and the model stays warm.
- A Discord bot token (Developer Portal).
- An Ollama instance with a chat model pulled.
- A Redis instance.
- Optional: a SearXNG instance with the JSON format enabled, for web search.
The image is published to GitHub Container Registry: ghcr.io/asvarnon/husk.
services:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis-data:/data
husk:
image: ghcr.io/asvarnon/husk:latest
restart: unless-stopped
env_file: .env
environment:
- CONTEXT_FORGE_DB=/data/husk.db # long-term memory; needs the volume below
volumes:
- husk-memory:/data
depends_on:
- redis
# Only if Ollama runs on the host rather than in this compose project:
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
redis-data:
husk-memory:Copy .env.example to .env, fill it in, then docker compose up -d.
All via environment variables (see .env.example). All are required except LLM_API_KEY, SEARXNG_URL, and CONTEXT_FORGE_DB; the bot exits at startup if a required one is missing.
| Var | Description |
|---|---|
DISCORD_TOKEN |
Bot token from the Discord Developer Portal |
LLM_BASE_URL |
OpenAI-compatible backend base URL — the full base including the version path, exactly as you'd give an OpenAI SDK. Husk appends only /chat/completions; it does not assume /v1. Examples: http://host.docker.internal:11434/v1 (Ollama), http://host:8080/v1 (llama.cpp), https://api.openai.com/v1 (OpenAI), https://api.z.ai/api/paas/v4 (Z.ai / GLM). |
LLM_MODEL |
Model name — used for both chat and distillation. e.g. gemma2:latest (Ollama), or the model id your runner reports. |
LLM_API_KEY |
Optional. Bearer token for hosted gateways (OpenAI, LM Studio, …). Local runners (Ollama, llama.cpp) ignore it. Applies to both chat and distillation. |
REDIS_URL |
Redis connection string, e.g. redis://redis:6379 |
SEARXNG_URL |
Optional. SearXNG JSON search endpoint, e.g. http://<host>:8888/search. Omit to disable web search (see Web search). |
PERSONA |
Full system prompt (multiline, double-quoted in .env) — the bot's identity |
CONTEXT_FORGE_DB |
Optional. Long-term memory SQLite path. Defaults to ~/.context-forge/discord.db. Point it at a mounted volume or memory resets on every redeploy. |
LEXICON_CONFIG |
Optional. Persona/domain lexicon TOML path. If set and missing, Husk bootstraps it from PERSONA on startup. |
CONTEXT_FORGE_ENGLISH_LEXICON |
Optional. true enables context-forge's generic English importance scorer. Defaults to false. |
CONTEXT_FORGE_PERSONA_LEXICON |
Optional. true enables Husk's persona/domain scorer from LEXICON_CONFIG. Defaults to true. |
RUST_LOG |
Optional. Tracing filter. A good default is husk::llm=debug,husk::search=debug,husk=info,context_forge=info,turso_core=warn. |
HUSK_LOG_LLM_BODY |
Optional. Set 1 to log full LLM request bodies for debugging. Default 0; dangerous because it logs prompt, user content, tool results, and retrieved memory. |
Backend-agnostic. Husk talks to any OpenAI-compatible runner. It appends
/chat/completionstoLLM_BASE_URL, so the base URL carries the version path itself (…/v1for OpenAI/Ollama/llama-server/LM Studio,…/api/paas/v4for Z.ai/GLM) — the same convention every OpenAI SDK uses. Switch backends by changingLLM_BASE_URL/LLM_MODELand restarting — no rebuild. The legacyOLLAMA_HOST/OLLAMA_MODELnames still work as aliases (LLM_*wins if both are set).Distillation backend: long-term-memory distillation reuses the same backend and endpoint as chat. As of context-forge 0.9.0 the distiller supports TLS (rustls) and bearer auth, so an authenticated
https://gateway (e.g. OpenAI) works —LLM_API_KEYis sent on distillation requests too.
PERSONA is the bot's entire personality — there is no hardcoded character. Example:
PERSONA="You are a terse, dry assistant in a friends' Discord. Answer directly. No preamble."Update .env and docker compose restart husk to change it; no rebuild needed.
Set LEXICON_CONFIG to a TOML file path. On startup, if the file doesn't exist the bot generates it automatically by asking the configured LLM to derive weighted terms from the PERSONA. The lexicon biases long-term memory recall toward domain-relevant entries — entries containing your persona's key nouns and speech patterns rank higher in the token-budget cut.
Lexicon scoring is deliberate and switchable:
# English/default scorer: generic commitment/decision/confirmation markers.
CONTEXT_FORGE_ENGLISH_LEXICON=false
# Persona/domain scorer: Cawl/domain lexicon from LEXICON_CONFIG.
CONTEXT_FORGE_PERSONA_LEXICON=trueCombinations are independent: both false means pure relevance, English only enables generic markers, persona only enables the domain lexicon, and both true uses context-forge's additive composite scorer.
Model quality matters for the auto-bootstrap. A small or low-reasoning local model will produce a sparse, poorly-weighted lexicon. If your wired model is weak, generate the file manually instead: the context-forge lexicon bootstrapping guide has the full prompt template in a copy-paste block — substitute your PERSONA, run it through Claude, ChatGPT, or any capable model in a browser, and save the TOML response to your LEXICON_CONFIG path. The bot skips the auto-bootstrap if the file already exists, so a manually generated file is used as-is.
Add or remove entries live with the /lexicon slash command:
/lexicon add term term:Battle-Sister weight:0.7
/lexicon add affirmation pattern:for the Emperor
/lexicon add negation pattern:scrapcode
/lexicon remove term term:Battle-Sister
/lexicon remove affirmation pattern:for the Emperor
/lexicon remove negation pattern:scrapcode
Changes take effect immediately — no restart needed.
Web search is off unless SEARXNG_URL is set. To enable it you need a reachable
SearXNG instance with the JSON format enabled — SearXNG disables
JSON by default, so you must add json under search.formats in its settings.yml:
search:
formats:
- html
- jsonA minimal way to run one alongside Husk in the same Compose project:
searxng:
image: searxng/searxng:latest
restart: unless-stopped
volumes:
- ./searxng:/etc/searxng # put settings.yml here with `json` in search.formats
# then set SEARXNG_URL=http://searxng:8080/search in .envSee the SearXNG Docker docs for full
setup. Leave SEARXNG_URL unset and the bot starts normally with web search disabled.
The bot's role needs Send Messages, Create Public Threads, Send Messages in Threads, and Use Application Commands, plus Manage Threads so /remember can archive a thread after committing it.
Global slash commands take up to one hour to propagate across all Discord servers. During local development, set DEV_GUILD_ID in .env to your test server's ID and the bot will register commands to that guild only — changes appear in seconds.
DEV_GUILD_ID=123456789012345678To get your server ID: Discord → Settings → Advanced → enable Developer Mode, then right-click your test server → Copy Server ID.
Remove DEV_GUILD_ID (or comment it out) before deploying to production. Global commands persist across restarts; guild commands are re-registered every time the bot connects.
cargo build --release
# binary at target/release/husk; reads the same env varsRequires a C toolchain (context-forge bundles SQLite).
Apache-2.0. See LICENSE.