No description
  • TypeScript 93.9%
  • CSS 3.1%
  • JavaScript 2.1%
  • Dockerfile 0.5%
  • Shell 0.3%
  • Other 0.1%
Find a file
Jacen Sekai 1cb4cb0ee8
Some checks failed
CI / Typecheck, build and test (push) Failing after 12s
CI / End-to-end (Playwright) (push) Successful in 1m6s
CI / Docker images and sandbox (push) Failing after 11s
CI / Compose stack smoke test (push) Failing after 6s
Merge pull request 'Let a conversation use a model profile instead of the main model' (#11) from conversation-model into master
Reviewed-on: #11
2026-10-28 10:20:36 -04:00
.github/workflows Add a CalDAV tasks integration and an optional Radicale service 2026-10-14 18:54:52 -04:00
defaults Initial commit: Lumina personal agent harness 2026-10-10 17:13:38 -04:00
docs Add a CalDAV tasks integration and an optional Radicale service 2026-10-14 18:54:52 -04:00
e2e Let a conversation use a model profile instead of the main model 2026-10-28 10:17:00 -04:00
integrations Merge branch 'freshrss-integration' 2026-10-22 12:11:24 -04:00
radicale Add a CalDAV tasks integration and an optional Radicale service 2026-10-14 18:54:52 -04:00
sandbox Add private web search via bundled SearXNG 2026-10-10 18:36:00 -04:00
scripts Add a CalDAV tasks integration and an optional Radicale service 2026-10-14 18:54:52 -04:00
sdk Add scheduled tasks (one-off and cron) run by the agent 2026-10-13 14:00:40 -04:00
searxng Add private web search via bundled SearXNG 2026-10-10 18:36:00 -04:00
server Let a conversation use a model profile instead of the main model 2026-10-28 10:17:00 -04:00
test Let a conversation use a model profile instead of the main model 2026-10-28 10:17:00 -04:00
web Let a conversation use a model profile instead of the main model 2026-10-28 10:17:00 -04:00
.dockerignore Initial commit: Lumina personal agent harness 2026-10-10 17:13:38 -04:00
.env.example Add a CalDAV tasks integration and an optional Radicale service 2026-10-14 18:54:52 -04:00
.gitignore Add test suites and CI; fix bugs they uncovered 2026-10-10 22:58:13 -04:00
CLAUDE.md Let a conversation use a model profile instead of the main model 2026-10-28 10:17:00 -04:00
docker-compose.yml Add a CalDAV tasks integration and an optional Radicale service 2026-10-14 18:54:52 -04:00
Dockerfile Run the app as an unprivileged user and sign file links 2026-10-13 22:00:50 -04:00
package-lock.json Add notifications (Web Push, ntfy) and flag conversations awaiting approval 2026-10-12 15:06:47 -04:00
package.json Add notifications (Web Push, ntfy) and flag conversations awaiting approval 2026-10-12 15:06:47 -04:00
playwright.config.ts Add notifications (Web Push, ntfy) and flag conversations awaiting approval 2026-10-12 15:06:47 -04:00
README.md Let a conversation use a model profile instead of the main model 2026-10-28 10:17:00 -04:00
tsconfig.json Add test suites and CI; fix bugs they uncovered 2026-10-10 22:58:13 -04:00

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 .md you 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.md with frontmatter). The agent can write its own.
  • MCP: stdio, Streamable HTTP and SSE servers. You can paste a Claude Desktop-style mcpServers JSON 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_search uses a bundled, self-hosted SearXNG instance by default, or Tavily or Brave Search if you add an API key. web_fetch reads 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 tasks integration 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.

The web integration gives the agent two tools:

  • web_search: returns titles, URLs and snippets, with optional category (news and 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.proxies in searxng/settings.yml.
  • web_fetch connects 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:

  1. In FreshRSS, allow API access under Settings → Authentication, then set an API password under Settings → Profile. This is separate from your login password.
  2. 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:8080 after setting LUMINA_BIND=0.0.0.0. Or use tailscale serve --bg 8080 for HTTPS without opening the port to your LAN.
  • Reverse proxy with HTTPS: Caddy example:
    lumina.example.com {
        reverse_proxy 127.0.0.1:8080 {
            flush_interval -1   # stream responses immediately
        }
    }
    
    With nginx, disable proxy_buffering for /api/ and raise client_max_body_size so attachments fit (see UPLOAD_MAX_MB). The app also sends X-Accel-Buffering: no.
  • LAN only: set LUMINA_BIND=0.0.0.0 in .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 serve is the easiest way) or localhost. 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. Set VAPID_SUBJECT to a real mailto: 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 notify hook 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.

  1. Turn on the service and set a password in .env, then run docker compose up -d --build:
    COMPOSE_PROFILES=tasks
    RADICALE_PASSWORD=<a long random password>
    
    The tasks integration switches itself on when RADICALE_PASSWORD is set. Check it under Settings → Integrations → Tasks (CalDAV).
  2. Make Radicale reachable from your phone. It listens on port 5232, bound to 127.0.0.1 like Lumina (see Remote access):
    • Tailscale: tailscale serve --bg --https=8443 5232, then use https://<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 send X-Script-Name.
    • LAN only: set RADICALE_BIND=0.0.0.0 and use http://<server-ip>:5232/. The password goes with every request, so only do this on a network you trust.
  3. In Tasks.org, open Settings → Synchronization → Add account → CalDAV and enter that URL, the username lumina (or your RADICALE_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.