No description
  • TypeScript 61%
  • Svelte 20.4%
  • JavaScript 13%
  • CSS 4.1%
  • Shell 0.7%
  • Other 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Luke 3e30493335
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
feat(chat-groups): module toggle + skill-shortcuts-style icon chips + theme fix
- 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
2026-09-29 09:20:45 +01:00
.forgejo/workflows chore: remove broken ha-bridge CI workflow (runner has no buildx; image is built manually) 2026-09-28 21:37:08 +01:00
extensions feat: unify pi-ha into pi-chat 2026-09-28 20:45:30 +01:00
ha-bridge fix(ha-bridge): SSH key auto-auth + HA 2026 map format (config mount) 2026-09-28 21:48:57 +01:00
prompts de-HA pi-chat: generic agent core, drop Home Assistant coupling 2026-08-27 16:05:44 +01:00
scripts feat: unify pi-ha into pi-chat 2026-09-28 20:45:30 +01:00
skills feat(image): confirm the enhanced prompt before generating 2026-09-20 21:35:09 +01:00
src feat(chat-groups): named filter groups above the sidebar search 2026-09-29 09:07:38 +01:00
test feat(ha-mode): composer toggle that primes the agent for Home Assistant work 2026-09-29 08:36:25 +01:00
web feat(chat-groups): module toggle + skill-shortcuts-style icon chips + theme fix 2026-09-29 09:20:45 +01:00
.dockerignore chore: fix .env.example (WORKER_HOST, PI_CHAT_DATA) and harden docker/gitignore 2026-08-28 17:29:10 +01:00
.env.example feat: unify pi-ha into pi-chat 2026-09-28 20:45:30 +01:00
.gitignore feat(context): bound injected context, dedupe memory writes, harden tool reach 2026-09-19 09:10:43 +01:00
compose.yaml feat: disable signups via DISABLE_SIGNUPS flag + logout button 2026-08-30 17:23:56 +01:00
docker-compose.dev.yml feat: expose /bridge on dev+test workers (env-unique Traefik routers) 2026-09-28 21:32:05 +01:00
docker-compose.local.yaml feat(restore): one-command recovery for chats lost to the per-account update 2026-09-16 10:12:26 +01:00
docker-compose.prod.yml feat: expose /bridge on dev+test workers (env-unique Traefik routers) 2026-09-28 21:32:05 +01:00
docker-compose.test.yml feat: expose /bridge on dev+test workers (env-unique Traefik routers) 2026-09-28 21:32:05 +01:00
Dockerfile feat(youtube): read video metadata and transcripts 2026-09-18 16:11:40 +01:00
icon.png feat: pi icon for HA sidebar (icon.png, icon: true in config.yaml) 2026-08-11 14:22:28 +01:00
LICENSE docs: public-readiness — MIT license, accurate README, generic seed 2026-08-21 11:48:58 +01:00
package-lock.json feat: unify pi-ha into pi-chat 2026-09-28 20:45:30 +01:00
package.json feat: unify pi-ha into pi-chat 2026-09-28 20:45:30 +01:00
README.md perf: cut boot, sidebar and long-conversation render cost 2026-09-18 09:33:53 +01:00
release.sh de-HA pi-chat: generic agent core, drop Home Assistant coupling 2026-08-27 16:05:44 +01:00
RESTORE-CHATS.md docs: a RESTORE-CHATS walkthrough for the upgrade that looks like data loss 2026-09-16 10:23:20 +01:00
THIRD_PARTY_NOTICES.md Add design mode, plans panel, provider OAuth, and model routing fixes 2026-09-10 18:36:05 +01:00
tsconfig.json feat: unify pi-ha into pi-chat 2026-09-28 20:45:30 +01:00
update.sh feat(updates): show admins when a newer pi-chat exists 2026-09-14 13:00:09 +01:00

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, \r progress 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, ⌃b and the punctuation phone keyboards bury. Sticky Ctrl applies to the next key (soft keyboard or bar), so Ctrl-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.