Genie is a development platform for projects that live on remote VMs. It provisions servers on several clouds (DigitalOcean, TazCloud, Hetzner, or any SSH box you bring), installs a standard toolchain on them through recipes, and lets you work on them from a browser: persistent tmux terminals, a durable Claude Code chat running on the VM, file and DB explorers, git, firewall, VS Code (code-server), user-defined AI agents in a Docker sandbox, an issue tracker, docs, team chat, and a large admin console.
It is an npm-workspaces monorepo with five packages. A Node manager process does all the work and talks to everything else over a single WebSocket protocol.
A project's server in the dashboard. Shown: live VM stats, the Manage-VM window, a persistent tmux SSH terminal, and the floating Claude Code chat streaming tool calls from the VM.
- Architecture at a glance
- Repository layout
- Packages
- Getting started (local development)
- Scripts reference
- Configuration (environment variables)
- Database
- Auth, roles and access control
- The WebSocket protocol
- HTTP endpoints
- Feature tour
- How VMs are managed
- Testing
- Deployment (Railway)
- Running behind a reverse proxy (sub-path mount)
- Conventions for contributors
- Further documentation
- Troubleshooting
┌──────────────────────────┐ ┌───────────────────────────┐
Browser ─────▶│ Renderer (Next.js 16) │ │ Chrome extension (MV3) │
(desktop, │ dashboard · /mobile · │ │ popup · side panel · │
/mobile) │ /extension iframe │ │ DOM actions on any tab │
└────────────┬─────────────┘ └────────────┬──────────────┘
│ WebSocket {type, payload} │
▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ Manager (Node 22, ws + http, port 9876) │
│ auth · ACL · ~35 handler modules · Drizzle/Postgres · AI SDK │
│ cloud APIs · SSH session cache · code-server proxy · MCP REST │
└───────┬───────────────────────┬───────────────────────┬────────┘
│ Postgres (DB) │ SSH (ssh2, optional │ HTTPS
▼ │ SOCKS5 via wireproxy) ▼
┌──────────┐ ▼ Anthropic, Fireworks,
│ Postgres │ ┌──────────────────────────┐ Gemini, RunPod, DO,
└──────────┘ │ Project VM │ TazCloud, Hetzner,
│ tmux · Claude Code CLI │ SendGrid, Slack, S3…
│ genie-stats daemon ───────┼──▶ POST /api/vps/stats
│ code-server :13337 │
│ Docker sandbox (agents) │
│ .mcp.json → /api/vps/mcp/*│
└──────────────────────────┘
Key ideas:
- One protocol. The renderer, the mobile UI, the extension side panel and the extension service worker all speak the same JSON-over-WebSocket protocol to the manager.
- The manager holds the SSH connections. Browsers never SSH to a VM directly. The manager keeps a session cache (one ssh2 client per host/port/user, channels multiplexed), and everything — terminals, Claude, file I/O, git, stats probes, VS Code — rides on it.
- Work survives disconnects. Terminals and Claude sessions run inside detached tmux on the VM, so they outlive browser reloads and manager restarts.
- VMs call back over HTTPS. The on-VM stats daemon and the VM's Claude Code MCP servers call the manager's REST endpoints using per-instance bearer tokens.
genie/
├── packages/
│ ├── manager/ Node WebSocket + HTTP server (the backend)
│ ├── renderer/ Next.js 16 dashboard (desktop, /mobile, /extension)
│ ├── chrome-extension/ Manifest V3 extension (webpack)
│ ├── vps-agent/ Tool-using LLM agent, runs in a Docker sandbox on the VM
│ └── vps-stats/ Metrics collector + systemd daemon that runs on the VM
├── knowledge/ "Concepts" docs (OKF bundle), synced with the knowledge_docs table
├── design-system/ UI handbook for the Genie look (tokens, primitives, patterns…)
├── docs/ API & architecture docs (TazCloud API, MCP browser, status…)
├── scripts/ Repo-level helper scripts (free-port-9876.mjs)
├── .github/workflows/ CI (vitest for renderer + manager)
├── .claude/launch.json Launch configs for the manager (9876) and renderer (3000)
├── CLAUDE.md Project conventions (state management, chat surfaces, logs)
├── railway.toml Railway build config (Nixpacks)
├── nixpacks.toml Nixpacks phases (setup / install / build)
├── wireguard.md WireGuard setup for reaching the TazCloud 10.128/16 network
└── docker-compose.yml Small sample compose file (nginx + redis), not used by Genie itself
The backend. ESM TypeScript ("type": "module"), run with tsx in dev and compiled with tsc
to dist/ for production.
Main dependencies: ws, ssh2, node-pty, drizzle-orm + postgres, ai (Vercel AI
SDK v6) with @ai-sdk/anthropic, @ai-sdk/fireworks and @ai-sdk/openai-compatible,
@google/generative-ai, google-auth-library, jsonwebtoken, @sendgrid/mail,
@slack/bolt, @aws-sdk/client-s3, socks, zod.
Boot sequence (src/index.ts):
- Load env from
packages/manager/.env.local, then.env(src/load-env.ts; values loaded first win). - Probe the public egress IPv4/IPv6 (logged as
[egress]). This tells you what to put intoMANAGER_PUBLIC_IP*. - Start wireproxy if
WG_PRIVATE_KEYis set (userspace WireGuard → local SOCKS5 for the TazCloud private network). A failure here is fatal. - Seed the built-in Claude agent user (
claude@genie.local). - Run the idempotent boot migrations (
src/db/migrate.ts). - Start the VPS-metric and SSH-event flushers.
- Upsert the built-in recipes and Claude plugins by slug.
- Strip embedded credentials (
user:token@) out of stored git remote URLs. - Create the HTTP + WebSocket server (
src/ws-server.ts), restore the Genie SSH key from the DB to disk, and start the background timers (see below). - Start the Slack bot (only if
SLACK_BOT_TOKENis set) and the RunPod idle watcher. - On SIGINT/SIGTERM: stop Slack and wireproxy, close sockets, flush metrics, exit.
Background timers in the server: DigitalOcean droplet status sync (60 s), presence
broadcast (3 s), WS ping/pong heartbeat (30 s; dead sockets are terminated), local
process/Docker monitoring, server metrics (1-hour per-second ring buffer plus per-minute
rollups), stdout/stderr log capture, the session janitor (first run after 30 s, then every
GENIE_SESSION_PRUNE_INTERVAL_MIN), daily retention janitors for analytics, audit and
connection logs, and the daily backup cron.
Source map (src/):
| Path | What lives there |
|---|---|
index.ts, ws-server.ts |
Boot, HTTP routing, WS upgrade, auth gate, ACL, handler chain |
handlers/ |
~35 WS handler modules (see the protocol section) |
auth/ |
Google OAuth, JWT, ws-acl.ts (role/namespace ACL) |
db/ |
Drizzle schema (schema.ts, ~49 tables), client, boot migrations |
chat/ |
AI chat models, Claude Code routing, durable chat turns, stream-json parser, team chat |
ssh/ |
Terminal layer: one PTY channel per session, tmux builders, durable Claude stream session |
vps/ |
SSH client/session cache/probe pool, handshake gate, file ops, firewall, code-server proxy, MCP servers, stats ingest, provisioning helpers |
cloud/ |
DigitalOcean, TazCloud, Hetzner, Railway clients, wireproxy launcher, VM locks/aliases |
agents/ |
Agent registry, runner, Docker sandbox |
projects/ |
Project service (membership, visibility) |
security/ |
Port scanner + web checks (headers, CORS, cookies, SSL, injection, …) |
notifications/ |
SendGrid email service, Slack bot |
logging/ |
Log ring buffers, monitor, server metrics, audit/analytics/connection logs |
runpod/ |
On-demand Kimi GPU pod + idle watcher |
tools/ |
Tools exposed to the floating AI assistant |
debug/ |
Debug HTTP API, SSH breadcrumb logging, SOCKS probe |
default-recipes.ts |
Built-in recipes seeded on boot |
*-service.ts |
Admin, backup, docs, knowledge, org, recipes, settings, tracker services |
scripts/ |
export-knowledge.ts, import-knowledge.ts, ssh-events-report.ts |
test-helpers/ |
Test DB setup, fixtures, WS test harness |
Other folders in the package:
scripts/:inspect-ssh-events.mjs(read-onlyssh_eventsdump),run-agent.ts(an end-to-end agent smoke test that skips the WS layer), andwatch-prod-logs.mjs(polls the debug logs endpoint).migrations/: hand-applied SQL files (see Database).drizzle/: an old drizzle-kit baseline. It is stale and is not used by tests or boot.TESTING.md,SECURITY-TESTING.md: the testing strategy (see Testing).
The web UI. Next.js 16 (App Router), React 19, Tailwind CSS v4, subjecto for state.
Notable libraries: @xterm/xterm (+ fit/canvas addons) for terminals,
@monaco-editor/react for editors, @xyflow/react for the architecture diagram,
three / @react-three/fiber / drei for the 3D topology, recharts for charts,
react-markdown + remark-gfm, Radix Switch/Tooltip, lucide-react, class-variance-authority.
next.config.ts:
output: "standalone", so the build produces a self-contained server for deployment.images.unoptimized,transpilePackages: ["react-markdown", "remark-gfm"].allowedDevOriginsandexperimental.serverActions.allowedOriginscome fromPUBLIC_HOST(comma-separated).NEXT_PUBLIC_WS_URLis baked into the build. The default iswss://api.genie.teleporthq.aiin production andws://localhost:9876otherwise.basePathis only set for sub-path deployments (see the reverse proxy section).
Real Next.js routes (src/app):
| Route | Purpose |
|---|---|
/[[...slug]] |
The main app shell. A catch-all whose client-side router (src/lib/routes.ts) picks the panel. |
/mobile |
Phone UI: home, server detail, Claude, terminal. Requires login and uses live data. |
/extension |
The UI loaded inside the Chrome extension's side panel. Tabs: commands, db, docker, files, git, team-chat, terminal, tracker, plus a Claude mode. |
/doc/[key] |
Public read-only view of a shared doc |
/invite/[token] |
Org/team invite landing page (preview → Google login → accept) |
/presentation |
Pitch deck |
GET /api/doc/[key], GET /api/invite/[token] |
Server-side proxies to the manager's public endpoints |
Client-side panels (inside the shell, URL → panel):
| URL | Panel | Minimum role |
|---|---|---|
/projects, /projects/:slug/{servers|members|settings} |
Project grid and project detail | user |
/agents |
AI agents | user |
/tracker |
Issue tracker (issues look like PREFIX-n) |
user |
/chat |
Team chat (DMs and rooms) | user |
/history |
Past Claude sessions and saved terminals | user |
/settings/{general|deploy|genie-local|org}, /settings/org/:orgId |
Settings; deploy and genie-local are admin only |
user |
/clouds/{do|taz|hetzner}, /clouds/taz/{vms|diagnostics} |
Cloud VM management | tazcloud |
/recipes |
Recipe catalog and editor | tazcloud |
/processes, /docker |
Local processes and Docker | admin |
/docs, /docs/…/file/:docId |
Docs with folders | admin |
/admin/:tab |
Admin console (see below) | admin |
/architecture |
xyflow architecture diagram | admin |
/topology |
3D topology graph | admin |
/users |
Connected users (live presence) | admin |
/security |
Security scanner | admin |
/ssh |
SSH sessions | admin |
/help |
Help | admin |
/server |
Manager server metrics | superadmin |
/knowledge |
"Concepts" (the knowledge bundle) | superadmin |
/logs |
Live manager logs | superadmin |
Unknown or forbidden URLs redirect to /projects. The same rules (navAllowedForRole) also
control what the sidebar shows.
Admin console tabs: database (DB explorer with SQL runner and saved queries; outside
production it also has a Drizzle push button), backup, droplets/{snapshots|templates|configs|sshkey}
("DO Build"), ai/{costs|settings}, users, teams, orgs, audit, prodlogs (Railway),
ssh-events. Superadmins also get communication (email), analytics, connections and
ssh-startups.
Always-mounted floating UI: Claude stream windows, terminal windows, Manage-VM windows (tabs: Manage, Firewall, Ports, Processes, Traffic, Claude Logs, Claude Memory, Claude Plugins, Skills, Commands, Files, DB, Github, Agents), VM connection windows, a file explorer, the WS log drawer, the floating Genie assistant, the review-changes (diff) panel, DM popups, deploy/build-log windows, the ⌘K command palette, and a reconnecting toast.
Source map (src/):
| Path | Contents |
|---|---|
app/ |
Next routes (above) and globals.css |
components/admin |
Admin panel, DB explorer, cloud panels, analytics, logs, users/orgs |
components/chat |
Claude chat (desktop window, shared ClaudeChatSurface, ChatMessageList, AskUserQuestion dialog, review-changes panel) and team chat |
components/project |
Projects, VPS cards, Docker, docs, tracker, recipes, security, clouds, architecture/topology, VS Code button, deploy windows |
components/tazcloud |
Manage-VM popup, VM connection (SSH terminal) windows, tmux session UI, snapshots |
components/terminal |
xterm windows (bridge in lib/terminal-bridge.ts) |
components/mobile |
Mobile app, screens, speech-to-text |
components/agents, knowledge, settings, file-explorer, cloud |
Their panels |
components/ui |
Primitives (button, card, select, menus, gauges, chart) and app chrome (sidebar, top bar, command palette, login) |
store/ |
State: types/, subjects/, actions/, handlers/ (see Conventions) |
lib/ |
ws.ts (WS client), routes.ts, dev-login.ts, hooks.ts, utils.ts (cn()), feature flags |
Styling. Tailwind v4 is configured in CSS only (there is no tailwind.config.ts). The
theme in src/app/globals.css is Catppuccin Mocha, with a compact type scale (text-md is
13 px body text), SF Pro/SF Mono font stacks, and a few animations (streaming border, Claude
thinking pulse, tmux glow) that respect prefers-reduced-motion. components.json is a
leftover shadcn config; components are hand-written. The full design rules are in
design-system/.
A Manifest V3 extension called "Genie Assistant". It does two things:
- It puts the Genie UI in Chrome's side panel (the renderer's
/extensionroute in an iframe), already scoped to the project of the current tab. - It runs DOM actions on your tabs on behalf of the AI (the
genie-browserMCP tools).
| Part | File(s) | Role |
|---|---|---|
| Service worker | src/background/service-worker.ts |
Holds the extension's WS connection to the manager (production: wss://api.genie.teleporthq.ai; dev builds try ws://127.0.0.1:9876 first). Backoff 1–30 s with jitter, 20 s keepalive ping. Identifies itself with extension:identify. Brokers extension:dom_action requests to the content script. |
| Project matcher | src/background/project-matcher.ts |
Maps the active tab's hostname to a project by VPS host or IP |
| Content script | src/content/content-script.ts, dom-actions.ts |
get_snapshot, click, type, select, scroll, read_text, read_attr, navigate, wait_for |
| Popup | src/popup/Popup.tsx |
Connection status, detected project, Sign in with Google, open side panel |
| Side panel | src/sidepanel/ |
iframe of http://localhost:3000/extension (falling back to https://genie.teleporthq.ai/extension) plus a postMessage bridge that shares the auth token and WS URL |
| Widget | src/widget/, src/content/floating-widget.ts |
Floating in-page widget. It is built but not currently injected. |
Permissions: activeTab, sidePanel, scripting, storage, tabs. Host permissions
cover localhost:9876, 127.0.0.1:9876 and localhost:3000.
How the AI drives your browser: the VM's Claude calls the genie-browser MCP tools
(browser_get_snapshot, browser_click, …). They go to the manager's MCP browser server,
which sends extension:dom_action to your extension socket and waits up to 15 s for
extension:dom_action_result. See docs/MCPD.md.
Build and load:
npm run build:extension # production build → packages/chrome-extension/dist
npm run dev:extension # watch modeThen open chrome://extensions, turn on Developer mode, click Load unpacked and
choose packages/chrome-extension/dist.
A small tool-using LLM agent (ai v6 + @ai-sdk/anthropic) that speaks newline-delimited
JSON over stdin/stdout. Today it powers the user-defined Agents feature, running inside
a throwaway Docker container on the project VM.
- Binary
genie-agent(src/index.ts):- Input messages:
init(apiKey, projectDir, maxToolRounds = 40, modelId, systemPrompt, allowedTools),chat(messages, context, domSnapshot),stop,browser:result. - Output messages:
ready,token,tool,done,error,stopped,browser:request.
- Input messages:
- Models:
claude-sonnet(the default) andclaude-opus. - Tools (
src/tools/):shell_exec: bash, 120 s default timeout and 600 s max; keeps 30 KB of output, the first 8 KB and last 22 KB.read_file: 1 MB cap; refuses paths outside the project directory.write_file,list_files(depth 5, 500 entries),search_files(50 matches).- The browser tools
view_pageanddom_action. - An
allowedToolsallowlist can restrict the set.
- Binary
genie-mcp(src/mcp-cli.ts): an older stdio↔unix-socket MCP relay. The VM's MCP servers now use HTTPS REST (/api/vps/mcp/*) instead, so it is legacy. - Deployment: the manager hashes
dist/*.js+package.jsonand compares that with/usr/lib/node_modules/@genie/vps-agent/.versionon the VM. If they differ, it uploads the files over SSH and runsnpm install --omit=dev(src/vps/vps-agent-rsync.ts). Agent runs then executedocker run --rm -i node:20-slimwith the agent mounted read-only at/opt/agentand/opt/projectmounted at/workspace.ANTHROPIC_API_KEYis injected so that it never appears inps.
A dependency-free metrics collector shared by the manager (types and parsers) and the VM (daemon).
collect.tsgathers:- CPU % (two
/proc/statsamples) - memory (
/proc/meminfo) - disk (
df /) - processes, plus listening and external ports (
ss,ps) - SSH sessions (
who) and established SSH connections - sshd
MaxStartups/ClientAlive*settings - "past MaxStartups" drop events from the journal
- CPU % (two
daemon.ts(genie-stats-daemon):- Flags:
--interval <sec>(default 5) and--output <path>. - Writes JSON lines to stdout and to the output file.
- If
GENIE_MANAGER_URL,GENIE_STATS_TOKEN,GENIE_PROJECT_IDandGENIE_INSTANCE_IDare all set, it also POSTs each sample to<manager>/api/vps/statswith a Bearer token.
- Flags:
parse-probe.ts: parses the output of the older SSH probe (manager polling), which is kept as a fallback.
On the VM it runs as the systemd unit genie-stats.service, installed by the
genie-standard recipe and writing to /run/genie/stats.jsonl. The manager installs a
drop-in (10-genie-postback.conf) with the postback env vars. It then ingests samples
(vps/stats-stream.ts), stores history in vps_metric_samples, and pushes live updates to
the UI as vps:stats:update.
- Node.js ≥ 22 (
engines.node) and npm. - PostgreSQL (any recent version; TLS is supported through
DB_CERT). - Build tools for
node-pty: python3, gcc and make. node-pty is used only by the manager-host terminal; the rest of the app works without it. - A Google OAuth client if you want real sign-in. On localhost you can skip it and use the dev login (see step 5).
- Optional: an Anthropic API key (Claude), cloud provider tokens, SendGrid, Slack and so on. See Configuration.
npm installThe manager's postinstall makes node-pty's spawn-helper prebuilds executable.
Create packages/manager/.env.local (gitignored, like all .env.* files). Minimal example:
DB=postgres://user:pass@localhost:5432/genie
GENIE_JWT_SECRET=<long random string>
GENIE_SUPERADMIN_EMAILS=you@example.com
GENIE_SECRET=<long random string> # encrypts stored SSH keys / tokens
GOOGLE_CLIENT_ID=... # optional on localhost (see dev login)
GOOGLE_CLIENT_SECRET=...
MANAGER_URL=http://localhost:9876 # OAuth redirect = $MANAGER_URL/auth/callback
FRONTEND_URL=http://localhost:3000
ANTHROPIC_API_KEY=sk-ant-... # needed for Claude features⚠ The Postgres variable is
DB, notDATABASE_URL.
packages/renderer/.env.local:
NEXT_PUBLIC_WS_URL=ws://localhost:9876If you don't set it, the default is ws://localhost:9876 in dev, and the WS client falls
back to production (wss://api.genie.teleporthq.ai) whenever the page is not served from
localhost.
npm run db:push -w @genie/manager # drizzle-kit push: schema.ts → DB
npm run build:vps-stats # the manager imports @genie/vps-stats/dist
npm run build:vps-agent # deployed to VMs by the managerSkipping the vps-stats build makes the manager crash with ERR_MODULE_NOT_FOUND.
npm run dev # manager (tsx watch, :9876) + renderer (next dev, :3000)dev:managerfirst runsscripts/free-port-9876.mjs, which SIGTERMs whatever is still holding the port.- Open
http://localhost:3000. The first non-agent user to sign in becomes admin and is validated automatically. - Dev login without Google: on
localhost/127.0.0.1, openhttp://localhost:3000/?login=you@example.com. That redirects through the manager's loopback-only/test-loginendpoint, which is disabled whenNODE_ENV=production.
Root package.json:
| Script | What it does |
|---|---|
npm run dev |
Manager + renderer concurrently (concurrently -k) |
npm run dev:manager |
Free port 9876, then PORT=9876 tsx watch packages/manager/src/index.ts |
npm run dev:renderer |
next dev in packages/renderer |
npm run dev:extension |
Extension webpack in watch mode |
npm run build |
Full build: vps-stats → vps-agent → manager → renderer → extension |
npm run build:vps-stats / build:vps-agent / build:manager |
tsc per package |
npm run build:renderer |
next build |
npm run build:extension |
Production webpack build |
npm start |
start:manager + start:renderer |
npm run start:manager |
node packages/manager/dist/index.js |
npm run start:renderer |
next start |
npm run manager |
Run the manager once with tsx (no watch) |
npm run knowledge:export |
DB knowledge_docs → knowledge/*.md |
npm run knowledge:import |
knowledge/*.md → DB (upsert by path, non-destructive) |
Package-level scripts:
| Package | Script | What it does |
|---|---|---|
| manager | test, test:watch |
vitest |
| manager | db:push |
drizzle-kit push |
| renderer | test, test:watch |
vitest (jsdom) |
| renderer | test:e2e, test:e2e:ui |
Playwright |
The manager reads packages/manager/.env.local, then .env. Several provider settings
(DigitalOcean, Hetzner, Railway, RunPod, Namecheap, GitHub PAT) can also be stored in the DB
(global_settings, editable in the UI). A DB value wins over the env var. The
DigitalOcean token can only be set in the DB.
| Variable | Default | Purpose |
|---|---|---|
DB |
required | Postgres connection string |
DB_CERT |
— | CA PEM for Postgres TLS |
PORT |
9876 |
HTTP/WS port |
NODE_ENV |
— | production disables /test-login |
MANAGER_URL |
http://127.0.0.1:$PORT |
Public manager URL. The OAuth redirect URI is $MANAGER_URL/auth/callback. |
FRONTEND_URL |
https://genie.teleporthq.ai |
Where the browser is sent after OAuth (?token=); also used for invite links |
RENDERER_URL |
http://localhost:3000 |
Redirect target for /test-login |
VPS_MANAGER_URL |
see note | URL that VMs use for stats postback and MCP REST. Falls back to a public MANAGER_URL, then https://api.genie.teleporthq.ai. |
| Variable | Purpose |
|---|---|
GENIE_JWT_SECRET |
JWT signing key (tokens last 30 days). Recommended in production. If it is unset, the manager generates a random secret once and stores it in global_settings (jwtSecret). |
GENIE_SECRET |
AES-256-GCM key (scrypt-derived) for stored SSH keys, git tokens and org credentials. Falls back to GENIE_JWT_SECRET. Pasting SSH keys is disabled if no real secret is set. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Google OAuth (scopes openid email profile) |
GENIE_SUPERADMIN_EMAILS |
Comma-separated emails that get the superadmin role on first sign-in. They also receive new-signup notifications. |
GENIE_DEBUG_SECRET |
Key for GET /api/debug/server-logs. packages/manager/scripts/watch-prod-logs.mjs reads it from the environment too. |
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY |
Claude models, the agent runner, and Claude on VMs |
FIREWORKS_API_KEY |
DeepSeek, Kimi and Qwen models through Fireworks |
GOOGLE_GENERATIVE_AI_API_KEY |
The web_search tool (Gemini with Google Search grounding) |
RUNPOD_API_KEY, RUNPOD_KIMI_POD_ID, RUNPOD_KIMI_ENDPOINT, KIMI_KEY, RUNPOD_KIMI_SERVED_MODEL (default kimi-k2.7-code) |
Self-hosted Kimi on a RunPod GPU pod (vLLM, OpenAI-compatible), started on demand and stopped when idle |
| Variable | Purpose |
|---|---|
TAZCLOUD_API_TOKEN, TAZCLOUD_PROJECT_ID |
TazCloud API (https://api.taz.ro) |
TAZCLOUD_SSH_PRIVATE_KEY |
Written to disk at boot and used for SSH to Taz VMs |
HETZNER_API_TOKEN |
Hetzner Cloud |
RAILWAY_TOKEN, RAILWAY_PROJECT_ID, RAILWAY_ENVIRONMENT_ID |
Railway GraphQL API, used by the admin "prod logs" viewer |
NAMECHEAP_API_USER, NAMECHEAP_API_KEY, NAMECHEAP_USERNAME, NAMECHEAP_DOMAIN |
DNS A records for custom subdomains on DigitalOcean droplets |
MANAGER_PUBLIC_IP, MANAGER_PUBLIC_IP_V6 (+ _DEV variants) |
Added to the UFW allowlist on provisioned VMs, and used as the Namecheap ClientIp |
GENIE_LOCAL_GITHUB_PAT |
GitHub PAT used by the genie-local recipe |
TazCloud v2 VMs live on private 10.128.N.0/24 networks. The manager reaches them through a
userspace WireGuard tunnel (wireproxy), which it can launch itself. See
wireguard.md and docs/API-v2.md.
| Variable | Default | Purpose |
|---|---|---|
WG_PRIVATE_KEY |
— | Turns on the built-in wireproxy launcher. Then WG_PEER_PUBLIC_KEY, WG_ENDPOINT and WG_ADDRESS are also required. Values can be inline or a file path. |
WG_ALLOWED_IPS / WG_KEEPALIVE / WG_MTU |
10.128.0.0/16 / 25 / 1420 |
Tunnel options |
WIREPROXY_BIN |
wireproxy |
Path to the binary |
GENIE_TAZ_SOCKS_HOST / GENIE_TAZ_SOCKS_PORT |
127.0.0.1 / 25344 |
Local SOCKS listener |
GENIE_TAZ_SOCKS |
set by the launcher | host:port. Set it yourself to use an external proxy. |
GENIE_TAZ_SOCKS_USER / GENIE_TAZ_SOCKS_PASS |
— | SOCKS authentication |
GENIE_TAZ_SUBNET |
10.128.0.0/16 |
Destinations that are routed through SOCKS |
GENIE_TAZ_SOCKS_HEARTBEAT_MS / _TARGET |
0 (off) / 10.128.0.1:22 |
Optional tunnel heartbeat |
| Variable | Default | Purpose |
|---|---|---|
GENIE_SSH_STATS_POSTBACK |
on (0 turns it off) |
VM daemon pushes stats to the manager |
GENIE_SSH_STATS_PROBE (legacy GENIE_SSH_STATS) |
off (1 turns it on) |
Fallback SSH polling probe |
GENIE_SSH_TMUX_PROBE |
on (0 turns it off) |
tmux session listing |
GENIE_STATS_DB_POLL |
auto | Poll stats from the DB; on automatically when MANAGER_URL is unset or localhost |
GENIE_STATS_FLUSH_MS |
30000 |
Metric batch flush interval |
GENIE_SSH_DEBUG |
off | Structured SSH breadcrumb logging |
GENIE_SESSION_RETENTION_DAYS |
30 (0 turns it off) |
Prunes old sessions, including Claude JSONL transcripts on VMs |
GENIE_SESSION_PRUNE_INTERVAL_MIN |
60 |
How often the session janitor runs |
GENIE_ANALYTICS_RETENTION_DAYS |
180 |
|
GENIE_AUDIT_RETENTION_DAYS |
30 |
|
GENIE_CONNECTION_LOG_RETENTION_DAYS |
30 |
| Variable | Purpose |
|---|---|
SENDGRID_API_KEY |
All outgoing email (signup alerts, backups, feedback, broadcast emails, genie-notify MCP) |
BACKUP_EMAIL |
Recipient of the daily backup; also the default sender |
COMMUNICATION_FROM_EMAIL |
Sender for broadcast emails from the admin Communication tab |
SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, SLACK_APP_TOKEN |
Slack bot (Bolt, socket mode), /genie command |
BUCKET_NAME, BUCKET_REGION, BUCKET_ACCESS_KEY_ID, BUCKET_SECRET_ACCESS_KEY, BUCKET_ENDPOINT_URL |
S3-compatible storage behind the genie-storage MCP |
DB_TEST (a separate test database, never the same as DB) and WS_INTEGRATION=1. See
Testing.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_WS_URL |
Manager WebSocket URL (baked in at build time) |
PUBLIC_HOST |
Comma-separated public hosts, allowed for dev origins and server actions |
NEXT_PUBLIC_GENIE_SSH_STATS_POSTBACK |
Live VM stats UI (on unless 0) |
NEXT_PUBLIC_GENIE_SSH_STATS_PROBE / NEXT_PUBLIC_GENIE_SSH_STATS |
Fallback probe UI (off unless 1) |
NEXT_PUBLIC_GENIE_SSH_TMUX_PROBE |
tmux session listing (on unless 0) |
Stack: PostgreSQL with drizzle-orm 0.44 on postgres.js. The schema is in
packages/manager/src/db/schema.ts (about 49 tables), and getDb() in src/db/index.ts is
a lazy singleton.
Tables by area:
| Area | Tables |
|---|---|
| Identity and orgs | users, organizations, org_members, teams, team_members, team_invites, org_credentials |
| Projects | projects (soft delete; holds the VPS instances), project_members, project_teams, deploy_logs, file_templates, vps_git_repos |
| Chat and AI | conversations, conversation_members, messages, chat_session_meta, assistant_chat_logs, assistant_session_state (maps to a Claude Code session id for --resume), ai_usage |
| Content | docs, doc_folders, doc_shares, knowledge_docs, tracker_labels, tracker_issues, tracker_issue_labels, tracker_issue_comments, saved_queries |
| Catalogs | recipes, claude_plugins, agents, agent_runs, base_image_template_history, global_settings (key/value: provider tokens, the Genie SSH keypair, …) |
| VMs and infra | server_credentials, vps_stats_tokens, vps_metric_samples, ssh_maxstartups_events, ssh_events, pty_sessions, cloud_vm_aliases, cloud_vm_locks, security_scans |
| Observability | audit_log (every WS message), connection_log, analytics_events, email_logs, server_metric_samples |
How the schema gets applied. There are three mechanisms, and it helps to know all of them:
drizzle-kit push. Runnpm run db:push -w @genie/manager, which diffsschema.tsagainst the live DB. This is the normal way to set up a fresh DB. In the Admin → Database tab, a superadmin can trigger it remotely (admin:drizzle:push); that first writes a backup and then runspush --force.- Boot migrations (
src/db/migrate.ts). About 20 idempotent raw-SQL steps, tracked in_genie_boot_migrations, that run on every start. migrations/*.sql. Hand-written, datedCREATE … IF NOT EXISTSfiles. No code applies them, so run them withpsqlif you need them.
The drizzle/0000_*.sql baseline is stale. Tests build their schema straight from schema.ts.
Backups (src/backup-service.ts): a logical SQL dump (INSERT statements) produced in
Node, skipping the large telemetry tables, written to ~/.genie/backups/backup-<ts>.sql.
You can list, create and delete backups from Admin → Backup. A daily midnight cron emails
the dump through SendGrid, but only when both SENDGRID_API_KEY and BACKUP_EMAIL are set.
Sign-in flow:
- When a socket connects, the manager sends
auth:required. - The client replies with
auth:token {token}if it has a stored token (localStorage["genie-auth-token"]). Otherwise it sendsauth:google:start. - The manager answers with
auth:google:url. The browser completes Google OAuth atGET /auth/callbackand is redirected toFRONTEND_URL?token=<jwt>. - The client stores the token, cleans the URL and authenticates the socket. On
auth:successit also resumes Claude streams and VM connections and accepts any pending invite.
New users:
- The first non-agent user becomes
adminand is validated automatically. - Emails listed in
GENIE_SUPERADMIN_EMAILSbecomesuperadminand are validated automatically. The role is only assigned when the account is created, so an existing user must be promoted in Admin → Users. - Everyone else starts as
userand unvalidated. The superadmin emails get a notification (SendGrid), and an admin validates the new user in Admin → Users. - Signing up with an invite token accepts the invite automatically.
- Admins get a default organization.
Roles (ordered): user < tazcloud < admin < superadmin. Superadmins can
impersonate other users (admin:impersonate:start/stop); a banner shows while
impersonating, and the JWT carries impersonatedBy.
Tenancy: organizations → teams → projects. A user sees a project through
project_members, project_teams, or org owner/admin rights.
Defense in depth. A role-gated feature has four layers
(knowledge/security/access-control.md):
- Sidebar visibility (client).
- Route guard
navAllowedForRole(client). - WS ACL
auth/ws-acl.ts(server). It checks the exact type override first, then the longest namespace prefix, and denies unknown types. It applies to inbound messages (canSend) and outbound ones (canReceive). - Ownership checks in the handlers (
handlers/handler-auth.ts:canAccessProject,hasRole). Every message that carries a client-supplied id must check ownership.
Every frame is JSON: { "type": "namespace:action", "payload": { … } }.
- Request/response: the client's
wsRequest()adds areqIdand resolves when a reply with the samereqIdarrives (10 s default timeout). Fire-and-forget messages usewsSend(). - Errors:
{type: "error", payload: {message}}, orerror:forbiddenfrom the ACL. - Heartbeat: the client sends
pingevery 20 s and closes the socket if nopongarrives within 45 s. The server pings at the protocol level every 30 s. - Reconnect: exponential backoff with jitter (1 s → 12 s in the renderer). It reconnects immediately when the browser comes back online or the tab regains focus. Close code 1013 starts further along the backoff.
- Server pipeline:
- Audit log.
ping→pong.auth:*.- Auth gate.
- ACL.
- Inline presence/extension messages.
- The handler chain. The first handler that returns
truewins; otherwise the client getsUnknown message type.
Handler modules and their namespaces (packages/manager/src/handlers/):
| Module | Message types | Role |
|---|---|---|
project-handler |
project:list/add/update/remove/start/stop/command:*, project:members:*, project:teams:* |
user |
vps-lifecycle-handler |
vps:deploy/connect/disconnect/test-connection/attach-existing/teardown/hibernate/wake/reboot |
user (attach-existing: admin) |
vps-runtime-handler |
vps:exec/status/logs/docker:logs/process:kill, vps:stats:*, vps:recipe:*, vps:claude-plugin:*, vps:mcp:ensure, vps:traffic:get |
user |
terminal-handler |
terminal:start/data/resize/close/inject/paste-image, ssh:list/kill/tunnel:reconnect |
user (ssh:*: admin) |
claude-stream-handler |
claude:stream:start/input/answer/stop/close/resize/bash/gitdiff/paste-image/list-sessions |
user |
fs-handler |
vps:fs:readDirectory/readFile/writeFile/rename/delete/upload/download |
user |
git-handler |
git:status/diff/log/branches/checkout/commit/pull/push/stage/stash/… |
user |
vps-git-repos-handler |
vps:git:repos:list/add/update/remove/clone/init/detect/set-auto-save |
user |
vps-db-handler |
vps:db:detect/databases/tables/query, vps:db:backup:* |
user |
firewall-handler |
vps:firewall:status/toggle/add/remove (UFW and the egress allowlist) |
user |
code-server-handler |
vps:code:ensure/status |
user |
agents-handler |
agents:list/get/upsert/delete/run/cancel |
user |
chat-handler |
Team chat chat:conversation(s):*, chat:message:*, chat:reaction:toggle; assistant chat:send/stop/resume, chat:session(s):* |
user |
tracker-handler |
tracker:list, tracker:issue:*, tracker:label:*, tracker:comment(s):* |
user |
docs-handler |
docs:*, docs:folder:*, docs:download:* (share, make public, ZIP) |
user |
file-template-handler, project-file-handler |
file-template:*, project-file:* |
user |
skills-registry-handler |
skills:registry:search |
user |
mcp-handler |
mcp:install |
user |
org-handler |
org:* (invites, members, teams, SSH key, Taz credentials and VMs) |
user + org-admin check |
recipes-handler |
recipes:list (user), recipes:create/update/delete (superadmin) |
— |
claude-plugins-handler |
claude-plugins:list (user), create/update/delete (superadmin) |
— |
knowledge-handler |
knowledge:list/create/update/delete/import/export |
superadmin |
do-handler |
do:deploy/…, admin:droplets:* |
tazcloud |
tazcloud-handler |
tazcloud:*, admin:tazcloud:*, admin:server:tunnel:* |
tazcloud |
hetzner-handler |
hetzner:*, admin:hetzner:* |
tazcloud |
baseimage-handler |
admin:baseimage:* |
admin |
db-handler |
admin:tables, admin:table:*, admin:row:*, admin:sql:execute, admin:db:download, admin:drizzle:push |
admin |
backup-handler |
admin:backups:list/create/delete |
admin |
admin-users-handler |
admin:users:*, admin:teams:*, admin:orgs:*, admin:impersonate:* |
admin / superadmin |
admin-misc-handler |
admin:ai:*, admin:audit:list, admin:connections:list, admin:email:*, admin:prodlogs:*, admin:sshkey:*, … |
admin / superadmin |
analytics-handler |
analytics:track, admin:analytics:summary |
user / superadmin |
admin-server-metrics-handler |
admin:server-metrics:*, admin:ssh-startups:list |
superadmin |
security-handler |
security:scans:list, security:scan:start/stop/delete |
admin |
runpod-handler |
runpod:start/stop/status |
admin |
local-pty-handler |
manager-pty:* (a shell on the manager host itself) |
superadmin |
local-fs-handler |
fs:* (the manager host's filesystem) |
user (ACL) |
misc-handler |
settings:*, feedback:submit, logs:*, monitor:set-interval, process:kill, docker:*, compose:*, db:saved-queries:* |
mixed |
Among the messages the server pushes: stats, project:list, project:log, logs:*,
chat:presence, presence:detail, vps:stats:update, terminal:output, claude:stream:*
and *:list:stale (cache invalidation).
The manager also serves plain HTTP on the same port:
| Path | Method | Purpose and auth |
|---|---|---|
/ |
GET | Health check: {"status":"ok"} |
/auth/callback |
GET | Google OAuth redirect target |
/test-login?email=&redirect= |
GET | Dev login. Loopback only, and returns 404 in production. |
/code/<projectId>/<instanceId>/… |
any + WS | Reverse proxy to code-server (127.0.0.1:13337 on the VM) over SSH. An HMAC ?gtoken= (valid 12 h) is exchanged for a path-scoped HttpOnly cookie. |
/api/vps/stats |
POST | Stats postback from the VM daemon (per-instance Bearer token) |
/api/vps/mcp/(tracker|security|notify|storage) |
POST | JSON-RPC MCP over REST for the VM's Claude Code (per-instance Bearer token) |
/api/public/doc/:publicKey |
GET | Public doc JSON |
/api/public/invite/:token |
GET | Invite preview |
/api/debug/server-logs?source=errors|manager|all&tail=N |
GET | In-memory log buffers. Accepts a superadmin JWT, or GENIE_DEBUG_SECRET as a Bearer token or in the X-Genie-Debug-Key header. |
CORS is * for GET/OPTIONS.
A project groups one or more VPS instances, members and teams, git repos, file templates, a tracker prefix, docs and agents.
- Servers can be deployed on DigitalOcean (from base images built in Admin → DO Build),
TazCloud or Hetzner, or attached as any SSH server you already have
(
connect-server-form,vps-bootstrap.ts). - You can test-connect, hibernate/wake, reboot and tear down servers from the UI.
- The provisioning scripts restrict SSH (UFW) to the manager's public IPs.
A recipe is a row with checkScript, installScript and uninstallScript bash scripts,
run as root over SSH. Built-ins live in src/default-recipes.ts and are upserted on every
boot; you can add more in the UI (superadmin).
The baseline recipe, genie-standard, installs:
- the
genieuser (with passwordless sudo) - Docker + compose
- Node
- Claude Code
- dtach
/opt/project- the
genie-statssystemd unit
Other recipes include dev services such as Next.js (logs to /var/log/nextjs-dev.log) and
ASP.NET Core (/var/log/dotnet-dev.log), code-server, Claude hardening, and genie-local,
which installs and upgrades a local Genie in place. See
knowledge/recipes/.
Claude plugins (skills, commands, MCPs) work the same way: a catalog in the
claude_plugins table that can be installed on VMs.
- Each terminal is an SSH PTY channel to the VM, wrapped in tmux, so sessions survive reloads and manager restarts.
- Sessions can be renamed, are listed with live "running" glows, and are kept in history.
- The browser side is xterm.js: output arrives as base64
terminal:output, and input goes out asterminal:data/terminal:resize. - Superadmins also get a node-pty shell on the manager host (
manager-pty:*).
The Claude chat runs the Claude Code CLI on the VM:
claude -p --input-format stream-json --output-format stream-json, inside detached tmux, with
a FIFO for stdin.
- The manager
tail -Fs the output and parses it (chat/stream-json-parser.ts) intoclaude:stream:*messages. - It supports queueing, stop, plan mode,
!cmdbash mode, image paste, slash-command autocomplete, dictation, the AskUserQuestion dialog, a context footer, git diff review, and--resumeacross sessions (assistant_session_state). - Desktop (floating windows) and mobile share one store and one
ClaudeChatSurface.
A separate in-dashboard assistant built on the Vercel AI SDK
(packages/manager/src/chat/chat.ts). The available models:
| Model id | Provider |
|---|---|
claude-opus, claude-sonnet |
Anthropic |
deepseek-v3, deepseek-v4-pro, kimi-k2.6, kimi-k2.7, qwen-3.6-plus |
Fireworks |
kimi-k2.7-runpod |
Self-hosted on RunPod (vLLM, OpenAI-compatible) |
claude-code |
Routed to the Claude Code CLI on the VM over SSH |
Its tools (src/tools/) include web_search (Gemini grounding), browse_url, project file
read/write/list, ssh_exec, project docs, save_agent_memory (AGENT.md) and
tracker_create_issue. Privileged roles also get TazCloud, DigitalOcean-domain and recipe
tools.
A turn keeps running if the socket drops and is replayed when the client reconnects
(durable-chat-turn.ts). Token usage and costs are recorded in ai_usage and shown in
Admin → AI → Costs.
When a VM is set up, the manager merges entries into /opt/project/.mcp.json on the VM
(vps/mcp-config-merge.ts). Each entry is an HTTP MCP that points back at the manager with
a per-instance bearer token:
| MCP | What it gives Claude |
|---|---|
genie-tracker |
Read and update the project's issues and comments |
genie-security |
Run and read security scans |
genie-notify |
Send email and chat notifications |
genie-storage |
S3-compatible object storage (put/list/get/delete, presigned URLs) |
genie-browser |
Drive your Chrome tabs through the extension (see docs/MCPD.md) |
User-defined AI agents (name, prompt, model, tool allowlist, timeout) are stored in agents,
and each run is recorded in agent_runs (queued → running → succeeded | failed | timeout | cancelled).
agents:runstartsrunner.ts, which opens the Docker sandbox on the project VM, runs thevps-agentinside it, and streams events back asagents:run:event/agents:run:complete.- The UI ships starter templates such as Codebase Guide and Build/Deploy Doctor.
- A Firecracker sandbox is planned.
Details: knowledge/agents/architecture.md.
"Open in VS Code" installs code-server on the VM if needed and opens
<manager>/code/<projectId>/<instanceId>/. The manager proxies HTTP and WebSocket traffic
over SSH forwardOut, so no per-VM domain or open port is needed. Access is gated by a
short-lived HMAC token that is exchanged for a cookie. Details:
knowledge/vps/code-server-proxy.md.
VM Claude sessions run with bypass permissions, so two layers bound what they can reach
(knowledge/security/claude-hardening.md):
- Managed settings in
/etc/claude-code/managed-settings.jsondeny reading~/.claude/**,.claude.json*and~/.ssh/**, and turn off non-essential traffic. - An egress allowlist firewall for the
genieUID (/usr/local/sbin/genie-firewall). A systemd timer re-applies it every 10 minutes, and it is managed from the UI (vps:firewall:*).
- Files: a file explorer with a Monaco editor, upload and download (
vps:fs:*). - Git:
- status, diff, log, branches, commit, pull/push, stash (
git:*) - registering repos with encrypted tokens, plus optional auto-save
- git remotes are stripped of embedded credentials at boot
- status, diff, log, branches, commit, pull/push, stash (
- Databases: DB detection on the VM, a table browser, a query runner and backups
(
vps:db:*). The manager's own DB has a full explorer in Admin → Database.
- Live VM stats come from the push-based daemon: CPU, memory, disk, processes, open/external ports, SSH sessions, and sshd MaxStartups health.
- History is kept in
vps_metric_samplesand drawn as sparklines. - Also available:
- a Traffic tab (SSH and VPS traffic)
- an SSH events flight recorder (
ssh_events, Admin → SSH Events) that classifies disconnect causes - SSH startup probes
- On the manager: server metrics, live logs, the WS log drawer, connected users and presence, audit log, connection log, and product analytics.
A two-phase scan of a target:
- A TCP scan of the top ports, in batches of 100, with banner detection.
- Web checks: headers, CORS, cookies, SSL, host header, methods, redirects, directory listing and injection.
Results are stored in security_scans. Admins can also run it as the genie-security MCP.
- Team chat: DMs and rooms, reactions, @Claude mentions with streaming replies, and notification toasts.
- Tracker: issues with per-project prefixes (e.g.
TER-12), labels, comments, assignees and drag reordering. - Docs: folders, sharing, public links (
/doc/<key>) and ZIP export. - Orgs: organizations, teams and reusable invite links (
/invite/<token>); per-org TazCloud credentials and SSH keys. - Slack:
/genie projects|stats|status|containers|processes|logs|exec|run|kill|firewall|ssh|teardown|help. - Email: broadcast emails from Admin → Communication, with logs in
email_logs.
Architecture docs about Genie itself, written as an OKF
bundle in knowledge/. They are stored in the knowledge_docs table and are viewable and
editable in the superadmin Concepts panel.
npm run knowledge:export # DB → knowledge/*.md (read the current Concepts)
npm run knowledge:import # knowledge/*.md → DB (upsert by path; deletions happen in the UI)Nothing is seeded automatically on boot.
/mobile is a phone-first UI with its own viewport settings and a +2 px font scale. It has
home (projects, A–Z), server detail, a real terminal, and the Claude screen (with dictation
and AskUserQuestion). It uses the same store and protocol as the desktop UI and requires
login.
- SSH layer (
packages/manager/src/vps/):ssh-session-cache.tskeeps one connection per host/port/user and multiplexes channels over it, serializing exec calls.ssh-probe-pool.tsuses a separate connection for stats/tmux probes, so a failed probe never kills an interactive session.ssh-handshake-gate.tsthrottles handshakes to stay under sshd'sMaxStartups.ssh-client.tshas an SSRF guard that blocks loopback, link-local and metadata IPs.
- Reaching private networks: TazCloud
10.128/16destinations are dialed through SOCKS5 (socks-dial.ts) over wireproxy. - Keys:
- Genie has its own SSH keypair, stored in
global_settingsand restored to disk at boot. It is managed in Admin → DO Build → SSH key. - Orgs can generate their own key.
- Keys you bring for your own servers are encrypted with
GENIE_SECRETinserver_credentials.
- Genie has its own SSH keypair, stored in
- On-VM conventions:
- The project lives in
/opt/project, owned bygenie. - The stats daemon writes
/run/genie/stats.jsonl. - Dev-service logs go to
/var/log/<service>-dev.log. - code-server listens on
127.0.0.1:13337.
- The project lives in
npm run build:vps-stats # required: the manager imports its dist/
npm test --workspace=packages/renderer # jsdom; store actions/handlers + routes
npm test --workspace=packages/manager # node; runs serially (shared test DB)The manager has three test tiers (packages/manager/TESTING.md):
| Tier | Needs | What it covers |
|---|---|---|
| 1. Pure logic | nothing | ACL, auth, parsers, crypto, formatters, … |
| 2. DB-backed services | DB_TEST=<separate postgres url> |
Services against a real DB. The schema is generated from schema.ts and tables are truncated before each test. |
| 3. WS integration | DB_TEST + WS_INTEGRATION=1 |
A real WS server with authenticated clients |
Without DB_TEST, the DB suites are skipped rather than failing. The setup refuses to run if
DB_TEST equals DB.
Security tests (packages/manager/SECURITY-TESTING.md)
enforce the invariant that a non-superadmin can only touch resources they own or are a member
of. The tests are ws-acl.test.ts, auth.test.ts and handlers/authorization.security.test.ts
(cross-tenant denial). Rule: every handler message that takes a client-supplied id needs an
ownership guard and a denial test. Note that canAccessProject returns true when
projectId is null.
cd packages/renderer
npm run test:e2e # or test:e2e:uiSet E2E_TEST_USER to the email of a validated superadmin in your dev DB. The tests that
log in are skipped without it. Playwright runs Chromium against http://localhost:3000. It starts or reuses the manager
(port 9876) and the renderer (port 3000). The specs are:
e2e/login.spec.ts: the login screen and Google button.e2e/golden-path.spec.ts: logs in through/test-login, then covers recipes and navigation.
.github/workflows/test.yml runs on pushes and PRs to main, with Node 22:
npm ci → build:vps-stats → renderer vitest → manager vitest. DB tiers are skipped in CI.
Production runs on Railway as two services from this monorepo, built with Nixpacks
(railway.toml, nixpacks.toml).
- Build command:
npm run build:manager && npm run build:vps-agent. Also buildvps-stats, because the manager imports itsdist/. The rootnpm run buildbuilds everything. - Start command:
node packages/manager/dist/index.js - Public URL: e.g.
https://api.genie.teleporthq.ai - Required env vars:
DBGENIE_JWT_SECRETandGENIE_SECRETGENIE_SUPERADMIN_EMAILSGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETMANAGER_URLandFRONTEND_URLANTHROPIC_API_KEY- plus whichever provider and notification vars you use.
- Build command:
npm run build:renderer.NEXT_PUBLIC_WS_URLmust be set at build time if you are not using the defaultwss://api.genie.teleporthq.ai. - Start command:
node packages/renderer/.next/standalone/packages/renderer/server.js - Public URL: e.g.
https://genie.teleporthq.ai - Note: this relies on
output: "standalone"innext.config.ts.
- python3, gcc, gnumake are needed to compile
node-pty, which the manager-host terminal uses. Without them that feature is unavailable in production, but everything else still works. - nodejs_22 is needed by Next.js 16, Tailwind v4 and the rest of the stack.
PORT is injected by Railway for both services.
Register ${MANAGER_URL}/auth/callback as an authorized redirect URI, and the frontend
origin as an authorized JavaScript origin. A mismatch produces redirect_uri_mismatch.
Genie normally runs at the root of its own host (renderer and manager on separate
origins). It can also be served under a sub-path of a shared host — e.g.
https://$HOST/$MOUNT for the renderer and a sibling path for the manager —
behind a single reverse proxy. That setup has a few requirements that differ from
the root-host deployment; miss any and the app silently hangs on the
"Connecting…" splash. Replace $HOST, $MOUNT (renderer mount, e.g.
app), $MGR_MOUNT (manager mount) and the ports with your own values.
basePath: "/$MOUNT"innext.config.tsso_next/*, assets and routes resolve under the mount path (the proxy only forwards that prefix).allowedDevOrigins+experimental.serverActions.allowedOriginsmust include the public host. In dev, Next runs on a different origin than the browser sees, so Next 16 blocks cross-origin dev requests (RSC/flight, HMR, server actions) unless the host is allowlisted — without it the client never finishes hydrating and hangs on "Connecting…". (Both are derived from thePUBLIC_HOSTenv var.)NEXT_PUBLIC_WS_URL=wss://$HOST/$MGR_MOUNT/so the browser reaches the manager through the proxy. The client resolves the WS URL insrc/lib/ws.ts; unset, it falls back to the compiled-in production manager.- Known gap:
/doc/[key]and/invite/[token]callfetch("/api/…")without the basePath prefix, so those two pages need the prefix added when mounted under a sub-path.
- Env loads from
packages/manager/.env.local/.env(seesrc/load-env.ts). The DB var isDB(notDATABASE_URL). - Build the workspace packages first — the manager imports
@genie/vps-stats/dist/…, so runnpm run build:vps-stats(andbuild:vps-agent) beforedev:manager, or it crashes withERR_MODULE_NOT_FOUND. - Port — the manager listens on
process.env.PORT || 9876. If both apps are launched under one process that injects a singlePORT, pin the manager so it doesn't take the renderer's port (dev:manageralready doesPORT=9876 npx tsx watch …). - OAuth (Google) — the redirect URI is
MANAGER_URL + /auth/callback(src/auth/auth.ts); the post-login redirect goes toFRONTEND_URL?token=. Behind a sub-path proxy, point these at the public URLs:MANAGER_URL=https://$HOST/$MGR_MOUNTFRONTEND_URL=https://$HOST/$MOUNTThen register the exact redirect URIhttps://$HOST/$MGR_MOUNT/auth/callback(and originhttps://$HOST) with the OAuth provider, or it returnsredirect_uri_mismatch.
- Route
/$MGR_MOUNT/→ the manager, forwarding the WebSocket upgrade (Upgrade/Connectionheaders) and stripping the prefix so the manager sees/…(this path carries the app WebSocket, the OAuth callback,/code/*for VS Code, and the/api/vps/*callbacks from VMs). - Route
/$MOUNT/→ the renderer. - In dev, Next devtools requests fonts at the root
/__nextjs_font/(not basePath-prefixed); route that to the renderer too, or those requests 404.
Any change to a manager/renderer
.env*file or anext.config.tsrequires restarting the dev servers to take effect.
The full list is in CLAUDE.md. The most important rules:
State management (renderer, subjecto):
- Use
Subject(fromsubjecto/core) for flat values andDeepSubject(fromsubjecto) for nested objects. Usebatch()for updates that touch several fields. - Name state objects with a
$prefix:$auth,$apps,$admin,$claudeStream. - In components, read state with
useSubject,useDeepSubject($s, 'path')or theuseDeepSubjectAllhelper. - The store is split into
types/→subjects/→handlers/(incoming WS message maps, merged inhandlers/index.ts) →actions/(what the UI calls, viawsSend/wsRequest).
Claude chat:
- Store first: new
claude:stream:*features go into the store (types → handlers → actions) before any component work. - No surface-only features: composer and interaction changes belong in the shared
ClaudeChatSurface. Keep desktop (claude-stream-window.tsx) and mobile (claude-screen.tsx) in sync.
Adding a WS message:
- Add a handler branch (or a new module in the handler chain).
- Add an ACL entry: unknown types are denied by default.
- Add an ownership check plus a denial test for any client-supplied id.
- Add the store handler and action on the client.
UI: follow the design system:
- dark only
- tokens from the
@themeblock inglobals.css, never raw hex values border-surface0as the default border- color meanings: peach = Claude, green = healthy, red = error
VM services: a recipe that installs a dev service must point its systemd unit's
StandardOutput/StandardError at /var/log/<service>-dev.log.
Concepts: when you change architecture, update knowledge/*.md and run
npm run knowledge:import.
| Document | Contents |
|---|---|
CLAUDE.md |
Project conventions |
knowledge/index.md |
Concepts: recipes, agents, access control, Claude hardening, VS Code proxy |
design-system/README.md |
UI handbook (tokens, color semantics, primitives, patterns, layout, motion, mobile, anti-patterns) |
docs/GENIE.md |
Older architecture overview of the deploy flow and VPS agent (partly outdated) |
docs/MCPD.md |
The genie-browser MCP: VM → reverse tunnel → manager → Chrome extension |
docs/API.md / docs/API-v2.md |
TazCloud VM API v1 / v2 (projects, private networks, bastion) |
docs/STATUS.md, docs/MAIN.md |
Feature checklist and product vision |
wireguard.md |
WireGuard access to the TazCloud private network |
packages/manager/TESTING.md, SECURITY-TESTING.md |
Testing strategy |
| Symptom | Likely cause and fix |
|---|---|
| The UI hangs on "Connecting…" | The WS URL is wrong or unreachable (NEXT_PUBLIC_WS_URL). In dev behind a proxy, the public host is missing from PUBLIC_HOST. Under a sub-path, basePath is missing. |
The manager crashes with ERR_MODULE_NOT_FOUND for @genie/vps-stats |
Run npm run build:vps-stats first |
EADDRINUSE :9876 |
A stale manager is still running. npm run dev:manager frees the port automatically; otherwise kill it with lsof -t -iTCP:9876. |
| The manager can't connect to the DB | The var is DB, not DATABASE_URL. For TLS, set DB_CERT. |
Google returns redirect_uri_mismatch |
Register exactly ${MANAGER_URL}/auth/callback |
| A new user signs in but sees nothing | They are unvalidated. An admin must validate them in Admin → Users. |
| The terminal doesn't work in production | node-pty failed to compile. Check that python3, gcc and make are in the build image. |
| Taz VMs (10.128.x) are unreachable | wireproxy isn't configured or running. Check the WG_* / GENIE_TAZ_SOCKS* vars and the [wireproxy] boot logs. Admins can restart it from the UI. |
| Live VM stats are missing | The genie-stats unit isn't installed or can't reach the manager. Check VPS_MANAGER_URL and systemctl status genie-stats on the VM. |
.env / next.config.ts changes have no effect |
Restart the dev servers |
