- TypeScript 61%
- Svelte 20.4%
- JavaScript 13%
- CSS 4.1%
- Shell 0.7%
- Other 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
deploy / test (push) Has been skipped
deploy / build-and-push (push) Has been skipped
deploy / deploy-test (push) Has been skipped
deploy / deploy-production (push) Has been skipped
deploy / build-dev (push) Successful in 1m54s
deploy / deploy-dev (push) Successful in 35s
- add chat-groups module toggle (server user-setting + /api/chat-groups-module + ModulesSettings switch + app.chatGroupsModule gate) - restyle group chips as 28px icon-only buttons (match skill shortcuts); click filters, right-click edits, + creates - fix GroupEditor modal locked to dark: use --surface-secondary / --input-fill-color theme tokens |
||
| .forgejo/workflows | ||
| extensions | ||
| ha-bridge | ||
| prompts | ||
| scripts | ||
| skills | ||
| src | ||
| test | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| compose.yaml | ||
| docker-compose.dev.yml | ||
| docker-compose.local.yaml | ||
| docker-compose.prod.yml | ||
| docker-compose.test.yml | ||
| Dockerfile | ||
| icon.png | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| release.sh | ||
| RESTORE-CHATS.md | ||
| THIRD_PARTY_NOTICES.md | ||
| tsconfig.json | ||
| update.sh | ||
pi-chat — a generic, agentic chat/coding UI
A self-hosted, multi-user web chat for general LLM use and agentic coding, backed by the Pi SDK agent runtime. Forked from pi-ha (the Home Assistant add-on) and generalized: Home Assistant coupling removed, multi-user auth (email/password + TOTP MFA) added, data moved to PostgreSQL.
Architecture
Two processes, one image:
| Process | Path | Role |
|---|---|---|
| Front door | web/ (SvelteKit 5, adapter-node) |
Public HTTP — auth (better-auth), SSR, proxies /api/* to the worker |
| Worker | src/server.ts |
Internal agent runtime — streaming chat, files, memory, specs, usage |
The front door listens on PORT (default 3000); the worker on WORKER_PORT
(default 3001), bound internally and protected by WORKER_SECRET. PostgreSQL
(+ Drizzle) holds auth, conversations index, and admin-set provider keys.
Requirements
- Node 24+
- PostgreSQL 16+
- At least one LLM provider key (see Model providers)
Run (dev)
# 1. Postgres (DATABASE_URL in .env)
# 2. worker
WORKER_SECRET=change-me DEEPSEEK_API_KEY=<key> node --experimental-strip-types src/server.ts
# 3. front door
cd web && npm install
BETTER_AUTH_SECRET=<32+ chars> DATABASE_URL=postgres://... WORKER_URL=http://127.0.0.1:3001 WORKER_SECRET=change-me npm run dev
See .env.example for every variable. The first user to sign up becomes
admin (sets provider keys in the Settings UI; enrolls TOTP for MFA).
BETTER_AUTH_URL (web/.env) must match the origin you actually browse — it is not
just cosmetics: better-auth ignores any request whose origin differs, the request
falls through to the /api/[...path] worker proxy, and every sign-in answers
401 Unauthorized. web/vite.config.ts pins the dev port with strictPort so
Vite cannot silently drift to the next free port when 5175 is taken; the dev app
is at http://localhost:5175.
Model providers
Models appear in the model dropdown once their key is set (admin UI or env).
| Env var | Provider |
|---|---|
DEEPSEEK_API_KEY |
DeepSeek |
ANTHROPIC_API_KEY |
Anthropic (Claude) |
OPENAI_API_KEY |
OpenAI |
GEMINI_API_KEY |
Google Gemini |
GROQ_API_KEY |
Groq |
XAI_API_KEY |
xAI (Grok) |
MISTRAL_API_KEY |
Mistral |
OPENROUTER_API_KEY |
OpenRouter (one key, many models) |
SERPER_API_KEY |
Serper — web-search results (optional) |
Run (Docker, on your own machine)
Builds the image locally — no container registry, no docker login — and runs
its own PostgreSQL. Nothing to install but Docker.
cp .env.example .env # then set WORKER_SECRET, BETTER_AUTH_SECRET, one provider key
docker compose -f docker-compose.local.yaml up -d --build
Open http://localhost:3000 — the first user to sign up becomes admin.
State (chats, files, ssh key) lives in the pi-chat-data volume, the database
in pi-chat-db; docker compose -f docker-compose.local.yaml down stops it
(add -v to wipe everything).
DATABASE_URL in .env is ignored by this stack (it talks to the bundled
database); set PI_CHAT_DATABASE_URL instead to use your own Postgres — see
the comments in docker-compose.local.yaml.
Updating
./update.sh # git pull + rebuild + restart — ~2 min, cached layers
.env and both volumes (pi-chat-data, pi-chat-db) are untouched, so
settings, chats and the database survive. It refuses to run if you edited a
tracked file — put overrides in .env and the pull stays a clean
fast-forward.
Upgrading an install from before multi-user
Older builds kept every account's chats, files and memories in one shared data directory. The per-user build cannot see them until they are moved into the account that owns them — the worker logs a warning at boot if it finds them. Step-by-step version, with rollback and the database check: RESTORE-CHATS.md.
legacy shared data found — run scripts/migrate-multiuser.sh …
DRY_RUN=1 scripts/migrate-multiuser.sh # show what would move
scripts/migrate-multiuser.sh # back up, move, restart
The script tars the whole volume to ./backups/ first, stops the stack, moves
the old root into /data/users/<first-admin>/ inside the worker image (as the
user the app runs as), repoints each session's recorded workspace and the
turn-metadata rows at the new paths, then starts the stack again. Global
entries — auth.json, models.json, settings.json, ssh/, repos/ — stay
where they are. Re-running is safe, nothing is overwritten, and the rollback
command is printed at the end.
Multi-user
Each account is an island. Sign-ups are open unless DISABLE_SIGNUPS=true;
the first user to sign up becomes admin.
| Per account | Shared (admin-only) |
|---|---|
| Chats + branches, file library, memories, skills, plans, drafts, documents, knowledge base, papers, design systems, usage/cost, model + module settings | Provider API keys, SSH host config and keys, the Terminal/tmux/SSH panels, host repos |
Data lives at /data/users/<user-id>/ (the base /data itself is the
"unassigned" tree used by background jobs — it holds no account's data once the
migration above has run). Sessions, uploads, skills and each account's
SQLite metadata database are separate files, so a second account cannot see
the first's, in the UI or through the agent: non-admin accounts get no
shell/host/LAN tools in their agent, and every session, file and usage request
is checked against the caller's own tree.
Moving a chat between accounts
Chats made before the migration belong to the first admin (nothing on disk says who wrote them). To hand one over — file, recorded workspace, turn metadata, tags and pins together:
PROJECT=pi-chat-dev COMPOSE_FILE=docker-compose.dev.yml ENV_FILE=.env.dev \
docker compose -p pi-chat-dev -f docker-compose.dev.yml --env-file .env.dev run --rm worker \
node --experimental-strip-types src/move-chats.ts \
--from <userId> --to <userId> --files <name>.jsonl [--files ...]
Add --dry-run first. A plain mv is not enough: the header records the
workspace (listings are scoped by it) and the metadata in each account's
database is keyed on the absolute session path, so both would be left behind.
Admins also get an Update available pill in Settings whenever upstream has
moved past the running commit: update.sh stamps that commit into the
container, and the app asks the public repo API what main is on now (cached
15 min, hidden when the box is offline).
Passwords
Change your own: Settings → Security → Password (current password, new password, and an option to sign your other devices out).
Forgot it, and you are locked out: on the sign-in screen choose Forgot password?, enter your email, and the server prints a one-time link to the console:
docker compose logs -f web
# pi-chat password reset: you@example.gg
# → /reset-password?token=gRmb9QEz1VseLEkcfsXDUY1u (valid 1 hour, single use)
Open that path, choose a new password, and sign in with it. The link works once and expires after an hour; the whole request is answered with the same generic message whether or not the account exists, so the form cannot be used to discover accounts. Your old password keeps working until the link is actually used — so someone triggering the form can at worst add lines to your log, never lock you out.
If an admin is available: Settings → Users → Reset password on the account generates a new random password instead of a link, signs that account out everywhere, and prints it to the same console:
docker compose logs -f web
# pi-chat password reset: you@example.gg → kR7fQt2mXpWz9bHnVc4L
There is no email — delivery is the container log, which is also why a printed link
or password is a bearer credential for anyone who can read those logs. Treat a
reset as "rotate it after signing in" (Settings → Security). If you add SMTP later,
sendResetPassword sends the same token by mail and the console line stays as the
fallback.
Terminal (admin-only)
The header's keyboard button (desktop rail too) opens the terminal, which is an app-native GUI by default:
- Sessions — the host's tmux sessions as cards (windows, panes, and the command each pane is running), plus a plain host shell with no tmux. Tap a pane to attach.
- Log — the attached pane's output as readable text (colours and cursor
codes stripped,
\rprogress redraws collapsed), with Run / Ctrl-C / Restart / Clear, a command bar, and "ask pi about this output" which hands the visible log to the composer. - Key bar — Esc, Tab, sticky Ctrl, arrows,
⌃band the punctuation phone keyboards bury. Sticky Ctrl applies to the next key (soft keyboard or bar), soCtrl-b n— the tmux prefix — works from a phone. - Take the wheel — swaps to the raw xterm terminal (vim, htop, splits) on the same PTY, so nothing re-attaches and the log is still there when you switch back.
The PTY is spawned by the worker (ssh → optional tmux attach), streamed as
SSE with input/resize as POSTs, and gated on the admin role at the front door —
a terminal is a shell on the host. tmux is the persistence layer: closing the
tab detaches and the host session keeps running. Sessions are still stored
separately from the pi TUI's, so the same conversation is not yet visible in
both — see mobile-terminal.todo.yaml for what landed and
.research/mobile-terminal.research-brief.yaml for that follow-up.
Deployment
Docker (two services from one image) — see compose.yaml (server, pulled
image) or docker-compose.{dev,test,prod}.yml (server, Traefik-exposed).
docker compose up -d --build
CI/CD is Forgejo Actions (.forgejo/workflows/deploy.yml): main builds and
deploys to dev, test and production run the full test gate then deploy.
Server layout follows the org convention — one folder /data/apps/pi-chat
with .env.dev/.env.test/.env.prod (including DOMAIN for Traefik).
Development
npm run check # type-check the worker (src/)
cd web
npm run lint # eslint
npm run check # svelte-check + tsc
npm run test # vitest
npm run build # adapter-node build
Host-shell access (SSH)
Optional. On first boot the worker generates an ed25519 key at
/data/ssh/id_ed25519. Point SSH_HOST/SSH_USER at the target host and add
the generated public key to its authorized_keys.
License
MIT — see LICENSE.