- TypeScript 93.9%
- CSS 3.1%
- JavaScript 2.1%
- Dockerfile 0.5%
- Shell 0.3%
- Other 0.1%
|
|
||
|---|---|---|
| .github/workflows | ||
| defaults | ||
| docs | ||
| e2e | ||
| integrations | ||
| radicale | ||
| sandbox | ||
| scripts | ||
| sdk | ||
| searxng | ||
| server | ||
| test | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| README.md | ||
| tsconfig.json | ||
Lumina
A self-hosted personal AI assistant with a first-class web UI. It runs in Docker, sandboxes everything the agent does inside a dedicated Docker-in-Docker daemon, talks to any OpenAI-compatible model API, and keeps its personality, values, style and memory in Markdown files that it can maintain itself.
- Web UI first: streaming chat with visible reasoning and tool calls, editing and regenerating messages, file attachments (paste, drop or pick; images go to vision models), and runs that keep going when you close the tab (reopen from another device and it picks up live). It works on phones, and settings for everything live in the UI.
- Sandboxed by default: every shell command runs in a per-conversation container on an isolated DinD daemon, never on your host's Docker. Stdio MCP servers run there too.
- Any OpenAI-compatible model: OpenAI, OpenRouter, Ollama, LM Studio, vLLM, llama.cpp, LiteLLM, and so on. Reasoning tokens are shown when the provider streams them.
- Self-maintained persona:
SOUL.md,VALUES.md,STYLE.md,USER.md,MEMORY.md(plus any other.mdyou add). The agent edits them with tools, and every change is versioned so you can review and revert it. - Skills: the Agent Skills folder format (
SKILL.mdwith frontmatter). The agent can write its own. - MCP: stdio, Streamable HTTP and SSE servers. You can paste a Claude Desktop-style
mcpServersJSON block to import servers. Remote servers can sign in with OAuth: tick Sign in with OAuth on the server, save, and press Sign in. Lumina registers itself with the server automatically, or uses a client ID you give it. The redirect URI is<your Lumina URL>/api/mcp/oauth/callback, so many providers will require Lumina to be served over HTTPS. - Web search:
web_searchuses a bundled, self-hosted SearXNG instance by default, or Tavily or Brave Search if you add an API key.web_fetchreads pages as clean Markdown. Both are on by default. - News feeds: the bundled FreshRSS integration lets the agent search and read your FreshRSS feeds.
- Scheduled tasks: reminders, daily briefings and recurring checks. Ask the agent ("remind me at 6 to call Sam", "every weekday at 8, brief me on the weather and news") or set them up in Settings → Schedules. Each run's reply arrives as a notification.
- Tasks on your phone: an optional bundled Radicale CalDAV server and the
tasksintegration let the agent find, add, edit and check off the tasks you keep in Tasks.org on Android, or in any other CalDAV task app. See Tasks. - Conversation search: the sidebar's search box finds past conversations by what was said in them (your messages and the agent's replies) and jumps to the matching message. The agent can search and read past conversations too (
conversation_search,conversation_read), so "what did we decide about the backups?" works across chats. - Custom integrations: drop a TypeScript module in
data/integrations/to give the agent new tools, per-turn context, config forms and storage. See docs/integrations.md.
Quick start
cp .env.example .env # set LLM_BASE_URL, LLM_API_KEY, LLM_MODEL (or set them later in the UI)
docker compose up -d --build
docker compose logs lumina # copy the generated access token
Open http://localhost:8080 and paste the token. Then click "Let's get acquainted" (or type it) to run the onboarding questionnaire. The assistant interviews you section by section and writes your answers into its persona files. The questionnaire lives in ONBOARDING.md, and you can edit it, rerun it any time with the same phrase, or skip it. The first tool call may take a few minutes while the sandbox image builds inside DinD. You can also start the build from Settings → Sandbox.
Using a local model
Ollama or LM Studio running on the host are reachable at http://host.docker.internal:11434/v1 or http://host.docker.internal:1234/v1. Both are presets in Settings → Model. Use a model with solid tool calling, such as Qwen3, Llama 3.3, Mistral Small or GPT-OSS. Small models tend to misuse tools. Set Context window to the context length the server actually runs the model with (Ollama's num_ctx, LM Studio's context length): local servers often default to a few thousand tokens and silently cut off anything longer.
Long conversations
Lumina fits every request into the context window set in Settings → Model. Once a conversation fills most of it, older messages are summarized by the model and the summary is sent in their place; you can see where in the chat, and read the summary. Long tool outputs from earlier steps are left out before anything else. The meter in the chat header shows how full the context was at the last request (the provider's token count where it reports one, otherwise an estimate), and Summarize now in it compacts a conversation on request.
Other models
New conversations are named by a model after their first message; until the title arrives, they show the message's first line. Summaries of long conversations are written by a model too. Both use the main model unless you add a cheaper one under Settings → Model → Other models and pick it under Tasks. A model there can share the main model's endpoint and key (leave its base URL empty, e.g. a smaller model on the same OpenRouter account) or use its own, such as a local Ollama model. Give it its own context window if it's smaller than the main model's: long transcripts are then summarized in more, smaller requests. Title generation can be turned off there as well.
A conversation can also chat with one of these models instead of the main one: pick it in the menu in the chat header (shown once there is another model). The choice is saved with the conversation and applies from the next request, even mid-run, and the context window used for that conversation is the model's own. If the model is later removed from settings, the conversation falls back to the main model.
Architecture
┌──────────────── docker compose ───────────────────────────────────────────┐
│ │
│ lumina (Node 24, TypeScript) dind (docker:28-dind) │
│ ┌───────────────────────────────┐ TLS ┌─────────────────────────┐ │
│ │ Fastify API + React web UI │───────▶│ lumina-sbx-<conv> … │ │
│ │ agent loop ─▶ LLM (OpenAI API)│ :2376 │ (shell tool sandboxes) │ │
│ │ tools: builtin / MCP / integ. │ │ MCP stdio servers │ │
│ │ integrations (trusted code) │ │ (docker run -i) │ │
│ └──────┬─────────────────┬──────┘ └────────────┬────────────┘ │
│ │ web_search │ /data │ /lumina-data │
│ ▼ └────── lumina-data volume ─┘ │
│ ┌───────────────────────────────┐ config.json · lumina.db · persona/ │
│ │ searxng (private metasearch, │ skills/ · integrations/ · workspace/ │
│ │ internal network only) │ (only workspace/ and skills/ (ro) │
│ └───────────────────────────────┘ are mounted into sandboxes) │
│ │
└───────────────────────────────────────────────────────────────────────────┘
| Path | What |
|---|---|
server/agent/ |
Agent loop (streaming, tool execution, history budget), system prompt, run manager (background runs + event replay) |
server/tools/ |
Tool registry, built-in tools (files, persona, skills, schedules, conversation search), Docker sandbox |
server/scheduler.ts, server/cron.ts |
Scheduled tasks: the timer loop, validation, and cron parsing |
server/mcp.ts |
MCP client manager |
server/mcp-oauth.ts |
OAuth sign-in for remote MCP servers |
server/integrations/loader.ts |
Integration discovery, lifecycle, hot reload |
sdk/ |
@lumina/sdk: types and defineIntegration for integration authors |
integrations/ |
Bundled integrations: web search/fetch (on by default), tasks over CalDAV, FreshRSS, ntfy, weather and Home Assistant examples |
searxng/ |
SearXNG image wrapper and settings.yml for the private search backend |
radicale/ |
Radicale image for the optional CalDAV server that syncs tasks with your phone |
defaults/ |
Persona files and skills copied into the data volume on first boot |
sandbox/Dockerfile |
Sandbox image: Node 24, Python 3, uv, git, ripgrep, jq, build tools |
web/ |
React + Vite web UI |
Data volume
Everything that matters lives in the lumina-data volume (/data in the app container):
config.json |
Settings saved from the UI (overrides .env values) |
lumina.db |
Conversations, persona history, integration storage (SQLite) |
persona/*.md |
Persona files |
skills/<name>/SKILL.md |
Skills |
integrations/<name>/index.ts |
Your integrations |
workspace/ |
Files shared between you and the agent (/workspace in the sandbox) |
.auth-token |
Generated access token |
Back up with docker run --rm -v lumina_lumina-data:/d -v "$PWD":/b alpine tar czf /b/lumina-backup.tgz -C /d .. The volume is named after your compose project; check docker volume ls.
To edit persona files, skills or integrations from your editor instead of the UI, swap the named volume for a bind mount in both services, for example ./data:/data and ./data:/lumina-data. The app runs as uid 1000 and takes ownership of the data directory when it starts, so set LUMINA_UID and LUMINA_GID to your own ids (id -u, id -g) to keep the files editable.
Web search
The web integration gives the agent two tools:
web_search: returns titles, URLs and snippets, with optional category (newsand more), time range, language and page. The first line of each result says which backend answered.web_fetch: downloads a page and extracts the main content as Markdown, dropping navigation, ads and images. Long pages are paginated. It refuses private and internal addresses (localhost, LAN, Tailscale/CGNAT, link-local and cloud metadata, and the other compose containers), checking every redirect hop. To allow them, turn on Allow private addresses in Settings → Integrations → Web search & fetch.
Choosing a search backend
Pick the backend under Settings → Integrations → Web search & fetch → Search backend. The agent's tools stay the same whichever you choose.
| Backend | Cost | Privacy | Reliability |
|---|---|---|---|
| SearXNG (default) | Free, self-hosted | No accounts or cookies; engines see only your server's IP | Depends on upstream engines, which sometimes CAPTCHA or rate-limit you |
| Tavily | Free plan: 1,000 credits/month, no card (1 per search) | Queries go to Tavily under your account | Stable API built for agents; may include a direct answer |
| Brave Search | Paid per query; new accounts get monthly credits | Queries go to Brave under your account | Stable API with its own independent index |
Tavily and Brave need an API key, entered in the same settings form; keys are stored in data/config.json and masked in the UI. Out-of-quota and invalid-key errors are passed to the agent so it can tell you. Category support differs by backend: Tavily and Brave understand general and news, while SearXNG also supports science, it, videos and others. If you don't use SearXNG, you can remove the searxng service and the lumina service's depends_on entry for it from docker-compose.yml.
SearXNG
The searxng service is a private metasearch engine on the internal compose network, with no published port.
What SearXNG does and doesn't hide:
- Search engines don't get cookies, a browser fingerprint or an account. They see requests from your server's IP, mixed in with no other users.
- To hide that IP too, route SearXNG through Tor or a VPN proxy with
outgoing.proxiesinsearxng/settings.yml. web_fetchconnects to sites directly from your server.
When searches return nothing, upstream engines are usually rate-limiting or showing CAPTCHAs to your IP. The tool reports which engines are down. Bing and Mojeek are enabled in addition to SearXNG's defaults (Google, DuckDuckGo, Brave, Startpage, Wikipedia) so that one blocked engine doesn't break search. Failed engines are retried after 5–10 minutes instead of SearXNG's default of up to a day.
To change engines or other settings, edit searxng/settings.yml and run docker compose up -d --build searxng. To use the SearXNG web UI yourself while tuning it, uncomment its ports: entry in docker-compose.yml. To use an existing SearXNG instance instead, change the URL in the integration settings; that instance must have json enabled under search.formats.
News feeds (FreshRSS)
The bundled FreshRSS integration connects to a FreshRSS instance through its Google Reader API. To set it up:
- In FreshRSS, allow API access under Settings → Authentication, then set an API password under Settings → Profile. This is separate from your login password.
- In Lumina, enable FreshRSS under Settings → Integrations. Enter the URL you open FreshRSS at, your username and the API password.
The agent gets these tools:
freshrss_search: keyword search across all feeds, or within one category or feed. It can also limit results to unread or starred stories.freshrss_recent: the newest stories.freshrss_article: one story's full text.freshrss_categories: the category and feed names, with unread counts.freshrss_mark: mark stories read or unread, or star or unstar them.
Unread counts per category are added to the system prompt, so the agent knows the feeds are there. You can turn that off in the integration's settings.
The API has no text search, so the integration does the searching itself. It scans the newest stories in the chosen category or feed, within a window that defaults to 14 days and is capped at 1,000 stories, both adjustable in settings. Narrow a search with a category or feed name to look further back.
Combined with scheduled tasks, this makes a news briefing. For example, ask "every morning at 7, summarise overnight motorsports and anime news from my feeds".
Remote access
The port is bound to 127.0.0.1 by default. Pick one of these for remote access:
- Tailscale (simplest): run Tailscale on the host and browse to
http://<host>.<tailnet>.ts.net:8080after settingLUMINA_BIND=0.0.0.0. Or usetailscale serve --bg 8080for HTTPS without opening the port to your LAN. - Reverse proxy with HTTPS: Caddy example:
With nginx, disablelumina.example.com { reverse_proxy 127.0.0.1:8080 { flush_interval -1 # stream responses immediately } }proxy_bufferingfor/api/and raiseclient_max_body_sizeso attachments fit (seeUPLOAD_MAX_MB). The app also sendsX-Accel-Buffering: no. - LAN only: set
LUMINA_BIND=0.0.0.0in.env.
Always set a strong LUMINA_AUTH_TOKEN (or keep the generated one) before exposing the port. Use HTTPS outside your LAN, because the token is a bearer credential.
Notifications
Lumina can notify you when a reply is ready, a scheduled task has run, or a tool call is waiting for your approval. Notifications are skipped for a conversation that's open in a visible tab. Choose which events notify you in Settings → Notifications.
- Browser push: press Turn on in Settings → Notifications on each device. This needs HTTPS (see Remote access;
tailscale serveis the easiest way) orlocalhost. On iPhone and iPad, first add Lumina to the Home Screen (Share → Add to Home Screen) and turn notifications on from the installed app, which also needs you to sign in again. SetVAPID_SUBJECTto a realmailto:address if Apple's push service rejects notifications. Payloads are end-to-end encrypted to your browser; the push service (Google, Mozilla or Apple) only relays them. - ntfy: enable the bundled ntfy integration with a topic you subscribe to in the ntfy app. On the public server anyone who knows the topic can read it, so use a long random one or your own server. Set Lumina URL to open the conversation when you tap a notification.
- Your own channel: an integration with a
notifyhook receives every notification (see docs/integrations.md).
Browser push is tested against a simulated push service (Mozilla's reference decryption and CDP-delivered pushes), not live Google, Mozilla or Apple endpoints.
Tasks
The agent can work with the tasks you keep in Tasks.org on Android, or in any app that syncs tasks over CalDAV (jtx Board, OpenTasks with DAVx⁵, Thunderbird, Apple Reminders). The tasks live on a CalDAV server, and both your phone and the bundled tasks integration sync with it. Lumina includes an optional Radicale server for this.
- Turn on the service and set a password in
.env, then rundocker compose up -d --build:
TheCOMPOSE_PROFILES=tasks RADICALE_PASSWORD=<a long random password>tasksintegration switches itself on whenRADICALE_PASSWORDis set. Check it under Settings → Integrations → Tasks (CalDAV). - Make Radicale reachable from your phone. It listens on port 5232, bound to
127.0.0.1like Lumina (see Remote access):- Tailscale:
tailscale serve --bg --https=8443 5232, then usehttps://<host>.<tailnet>.ts.net:8443/. - Reverse proxy: with Caddy,
tasks.example.com { reverse_proxy 127.0.0.1:5232 }. Serve it at the root of a host name; a sub-path needs the proxy to sendX-Script-Name. - LAN only: set
RADICALE_BIND=0.0.0.0and usehttp://<server-ip>:5232/. The password goes with every request, so only do this on a network you trust.
- Tailscale:
- In Tasks.org, open Settings → Synchronization → Add account → CalDAV and enter that URL, the username
lumina(or yourRADICALE_USER) and the password. Create a list under the new account, or ask the agent to. Tasks already on your phone are in local lists; to sync them, select them and Move them to a CalDAV list.
The agent can then find, add, edit, complete and delete tasks and create lists (the tasks_* tools). It also sees overdue tasks and tasks due today in its prompt; you can turn that off in the integration's settings. Changes reach your phone at Tasks.org's next sync; pull down on a list to sync right away.
- Edits keep everything else Tasks.org stores (sort order, collapsed subtasks, places, snoozes). If you change a task on your phone while the agent is editing it, the agent rereads it and applies its change on top, so your edit is kept.
- Completing a repeating task moves it to its next date, as checking it off in the app does. Tasks.org doesn't sync "Repeats from: completion date", so tasks the agent completes always repeat from their due date.
- Reminders are notifications from Tasks.org on your phone. New tasks the agent adds without reminders get your Tasks.org default reminders. For something the agent should do at a set time, use a scheduled task (Settings → Schedules) instead.
- Deleting a task asks for your approval by default. The other task tools don't.
To use another CalDAV server instead, leave out the tasks profile and set the integration's CalDAV URL, username and password (Nextcloud: https://<host>/remote.php/dav/ with an app password).
Security model
| Runs where | Trust |
|---|---|
| Shell tool, sandboxed MCP servers: containers on the DinD daemon | Untrusted. They see /workspace (rw) and /skills (ro) only, with no access to config.json, API keys, the database or the host Docker socket. CPU, memory and PID limits apply, plus no-new-privileges. Set the network to none for full offline isolation. |
| App process | Runs as an unprivileged user (uid 1000, or LUMINA_UID) with no-new-privileges. The entrypoint starts as root only to hand the data volume to that user. Shell commands run as the same user inside the sandbox, so the files they write stay editable; the agent can ask for root in the sandbox (as_root) to install packages. |
File tools: app process, confined to data/workspace |
Paths are resolved through symlinks, so links the sandbox creates can't escape the workspace. Download and image links in the UI are signed for one file and expire after about an hour, so the access token never appears in a URL. |
| Integrations, unsandboxed MCP servers: app process | Trusted code with full access to the app container. Only install integrations you wrote or reviewed. |
| DinD daemon | Runs privileged (DinD requires it) and is reachable only on the internal compose network with mutual TLS. This isolates the agent from your host's Docker. It is not a VM-grade boundary: a container-escape kernel exploit could reach the host. For stronger isolation, run the stack in a VM. |
Each tool has an approval policy, set in Settings → Tools per tool or per source: Allow runs it without asking, Ask first pauses the run until you press Allow or Deny on the tool call, and Block hides it from the agent. MCP tools and integration tools marked dangerous (like Home Assistant's call_service) ask first by default; everything else is allowed, since built-in tools act only in the sandbox, the workspace and the versioned persona files. The system prompt also tells the agent to confirm destructive or outward-facing actions with you first. Integration-level switches add a further layer, like the allow_services flag in the Home Assistant example.
Development
npm install && npm --prefix web install
npm run dev # API on :8080 (node --watch, native TypeScript type stripping, no build step)
npm run web:dev # Vite dev server on :5173, proxies /api to :8080
npm run typecheck
npm test # unit + API tests (no network or Docker needed)
npm run test:e2e # browser tests with Playwright (run `npx playwright install chromium` once)
npm run test:smoke # full Docker Compose stack end to end (a few minutes)
node scripts/mock-llm.mjs # optional: fake OpenAI-compatible model on :9999 for testing the agent loop
Without DOCKER_HOST set, local dev uses your host's Docker daemon for sandboxes. Data goes to ./data unless LUMINA_DATA_DIR is set.
Server code is plain TypeScript run directly by Node 24, so it must use erasable syntax only: no enum and no parameter properties, and relative imports need .ts extensions. tsc is only used for type checking.
Configuration reference
Environment variables (seed defaults; UI settings override them once saved):
| Variable | Default | |
|---|---|---|
LLM_BASE_URL |
https://api.openai.com/v1 |
OpenAI-compatible endpoint |
LLM_API_KEY |
||
LLM_MODEL |
||
LLM_TEMPERATURE, LLM_MAX_TOKENS |
provider default | |
LLM_CONTEXT_TOKENS |
64000 |
Context window to fit requests into: the model's context length, or less to cap cost |
LLM_AUTO_COMPACT |
true |
Summarize older messages when a conversation outgrows the window; false drops them instead |
LLM_VISION |
true |
Send attached images to the model; set false for text-only models |
LLM_GENERATE_TITLES |
true |
Name new conversations with a model; false uses the first line of the first message |
UPLOAD_MAX_MB |
100 |
Largest file you can attach. Raise your reverse proxy's limit to match (nginx: client_max_body_size) |
AGENT_MAX_ITERATIONS |
40 |
Tool-use rounds per reply |
TZ |
UTC |
Timezone for the date/time the agent sees and for repeating schedules, e.g. Europe/Lisbon |
LUMINA_AUTH_TOKEN |
generated | Web/API access token |
LUMINA_BIND / LUMINA_PORT |
127.0.0.1 / 8080 |
Published address (compose) |
LUMINA_UID / LUMINA_GID |
1000 / same as uid |
User the app runs as; 0 keeps root |
SANDBOX_IMAGE |
lumina-sandbox:latest |
Built from sandbox/ if missing; other images are pulled |
SANDBOX_NETWORK |
bridge |
none to disable network |
SANDBOX_MEMORY_MB, SANDBOX_CPUS |
2048, 2 |
Per-sandbox limits |
SANDBOX_TIMEOUT_SEC |
300 |
Default shell command timeout |
SANDBOX_IDLE_MINUTES |
30 |
Idle sandboxes are removed (workspace files persist) |
SEARXNG_SECRET |
random per start | Fixed SearXNG secret key (optional) |
COMPOSE_PROFILES |
none | tasks starts the Radicale CalDAV server (see Tasks) |
RADICALE_USER / RADICALE_PASSWORD |
lumina / none |
Radicale login, also used by the tasks integration. Radicale won't start without a password |
RADICALE_BIND / RADICALE_PORT |
127.0.0.1 / 5232 |
Published address of Radicale (compose) |
VAPID_SUBJECT |
mailto:lumina@example.com |
Contact sent to browser push services; set a real address for Safari/iOS |
Roadmap
Planned features, known limitations and contributor notes are in CLAUDE.md.