A self-hosted, mobile-first SSH terminal with a built-in AI coding assistant. Connect to any server from your phone or browser, chat with an LLM about what's on screen, run commands via an agent, browse files over SFTP, and hear responses spoken aloud via server-side TTS.
| Feature | Description |
|---|---|
| SSH Terminal | Full xterm.js terminal, keyboard shortcuts, mobile key bar |
| AI Chat | Persistent chat with any OpenAI-compatible LLM |
| Agent Mode | AI proposes → confirms → executes shell commands via SSH |
| Live Voice | Hold-to-speak mic input; server-side Piper TTS reads responses |
| SFTP Browser | Browse, upload, download, and edit files in-browser |
| Snippets | Save and one-click-run command snippets |
| Saved Connections | Store SSH credentials in PostgreSQL |
| GitHub Memory | AI memory and session logs backed to a GitHub repo |
| Auto-reconnect | Exponential backoff reconnect; immediate reconnect on app resume |
┌─────────────────────────────────────────────────────┐
│ Browser / Phone │
│ │
│ artifacts/terminal-ai (Vite + React) │
│ ┌──────────────────────────────────────────────┐ │
│ │ main.tsx — all app state & logic │ │
│ │ components/ │ │
│ │ file-editor.tsx — SFTP in-browser editor │ │
│ │ memory-panel.tsx— GitHub memory viewer │ │
│ │ layout.tsx — shell chrome │ │
│ │ hooks/ — shared React hooks │ │
│ └──────────────────────────────────────────────┘ │
└──────────────┬──────────────────┬───────────────────┘
│ REST /api/* │ WebSocket /api/ssh/ws
▼ ▼
┌─────────────────────────────────────────────────────┐
│ artifacts/api-server (Express 5, Node.js) │
│ │
│ routes/ │
│ health.ts GET /api/healthz │
│ ssh-connections.ts CRUD /api/ssh-connections │
│ snippets.ts CRUD /api/snippets │
│ ai-settings.ts GET/PUT /api/ai-settings │
│ chat.ts POST /api/chat (+ agent) │
│ sftp.ts GET/POST /api/sftp/* │
│ memory.ts GET/PUT /api/memory │
│ tts.ts POST /api/tts (Piper/espeak) │
│ │
│ ssh-ws.ts WebSocket SSH bridge │
│ sftp-helper.ts SFTP stream helpers │
│ lib/logger.ts pino structured logger │
│ services/github.ts read/write GitHub files │
└──────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Shared Libraries (lib/) │
│ │
│ lib/db Drizzle ORM + schema │
│ lib/api-spec OpenAPI 3 spec (source of │
│ truth for codegen) │
│ lib/api-zod Zod request/response schemas │
│ lib/api-client-react React Query hooks (generated) │
└──────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ PostgreSQL │
│ │
│ ssh_connections saved server credentials │
│ snippets command snippets │
│ ai_settings LLM config + GitHub token │
│ chat_messages persistent chat history │
└─────────────────────────────────────────────────────┘
Internet → Cloudflare Tunnel → nginx :80
├─ /api/* → Express :3001
└─ / → Vite static build
WebSocket (/api/ssh/ws) is proxied by nginx with Upgrade headers and handled by the same Express process via the ws package.
ssh_connections
id, name, host, port, username
auth_type ("password" | "key")
password, private_key, passphrase
created_at, updated_at
snippets
id, title, command, description, category
created_at
ai_settings (single-row table)
id, api_key, endpoint_url, model_name
system_prompt, github_token, github_repo
updated_at
chat_messages
id, role, content
command (agent-mode command, if any)
terminal_context (terminal snapshot sent with message)
created_at
.
├── artifacts/
│ ├── terminal-ai/ Frontend (React + Vite)
│ │ └── src/
│ │ ├── pages/main.tsx ← main app (SSH, chat, voice, SFTP)
│ │ ├── components/
│ │ │ ├── file-editor.tsx ← in-browser file editor
│ │ │ ├── memory-panel.tsx← GitHub memory UI
│ │ │ └── layout.tsx
│ │ ├── hooks/
│ │ └── lib/utils.ts
│ └── api-server/ Backend (Express 5)
│ └── src/
│ ├── app.ts ← Express app setup
│ ├── index.ts ← HTTP server + WS init
│ ├── ssh-ws.ts ← WebSocket SSH bridge
│ ├── sftp-helper.ts
│ ├── lib/logger.ts
│ ├── services/github.ts
│ └── routes/
│ ├── index.ts ← route registration
│ ├── health.ts
│ ├── ssh-connections.ts
│ ├── snippets.ts
│ ├── ai-settings.ts
│ ├── chat.ts ← AI chat + agent mode
│ ├── sftp.ts
│ ├── memory.ts
│ └── tts.ts ← Piper TTS
├── lib/
│ ├── db/ Drizzle ORM + schema + migrations
│ ├── api-spec/ OpenAPI 3 spec (YAML)
│ ├── api-zod/ Zod schemas (generated from spec)
│ └── api-client-react/ React Query hooks (generated from spec)
├── scripts/ Shared utility scripts
├── install.sh Fresh server install (Ubuntu/Debian)
├── update.sh Pull + rebuild + PM2 reload
├── pnpm-workspace.yaml
└── README.md
Set in /opt/terminal-ai/.env on the server. Loaded by PM2 via ecosystem.config.cjs.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
SESSION_SECRET |
Yes | Express session signing secret |
PORT |
Yes | API server port (default 3001) |
PIPER_BINARY |
No | Path to piper binary (default /usr/local/bin/piper) |
PIPER_MODEL |
No | Path to .onnx voice model |
NODE_ENV |
Yes | production on server |
Requires Ubuntu 22.04+ or Debian 12+. Run as root or sudo user.
curl -fsSL https://raw.githubusercontent.com/YOUR/REPO/main/install.sh | bashOr clone then run:
git clone https://github.com/YOUR/REPO.git /tmp/terminal-ai-src
bash /tmp/terminal-ai-src/install.shThe script will ask for: GitHub repo URL, install directory, domain/IP, DB credentials. It installs Node.js, pnpm, PostgreSQL, nginx, PM2, builds the app, and sets up nginx + Cloudflare tunnel.
bash /opt/terminal-ai/update.shThis runs git pull, pnpm install, rebuilds both artifacts, runs DB migrations, and reloads PM2 — zero downtime.
If you cannot push from development to Git, use the APPLY.sh method:
# On your local machine
scp terminal-ai-update.tar.gz ubuntu@YOUR_SERVER:/tmp/
# On the server
cd /tmp && tar -xzf terminal-ai-update.tar.gz
bash terminal-ai-update/APPLY.sh /opt/terminal-ai# Install deps
pnpm install
# Run API server
pnpm --filter @workspace/api-server run dev
# Run frontend
pnpm --filter @workspace/terminal-ai run dev
# Typecheck everything
pnpm run typecheck
# Run DB migrations
pnpm --filter @workspace/db run migrateNever run
pnpm devat the workspace root — there is no root dev script by design.
- Add the endpoint to
lib/api-spec/(OpenAPI YAML). - Run
pnpm --filter @workspace/api-spec run codegento regenerate Zod schemas and React Query hooks. - Add route handler in
artifacts/api-server/src/routes/. - Register it in
artifacts/api-server/src/routes/index.ts.
- Add the table definition to
lib/db/src/schema/index.ts. - Generate and run the migration:
pnpm --filter @workspace/db run generate pnpm --filter @workspace/db run migrate
pm2 list # show all processes
pm2 logs terminal-ai-api # tail live logs
pm2 logs terminal-ai-api --lines 200 # last 200 log lines
pm2 reload terminal-ai-api # zero-downtime reload
pm2 restart terminal-ai-api # hard restart
pm2 stop terminal-ai-api # stop
pm2 delete terminal-ai-api # remove from PM2
pm2 save # persist process list across reboots
pm2 startup # print systemd enable commandRestart with fresh env vars (e.g. after editing .env):
pm2 delete terminal-ai-api
cd /opt/terminal-ai && pm2 start ecosystem.config.cjs
pm2 savenginx -t # test config syntax
systemctl reload nginx # reload without dropping connections
systemctl restart nginx # full restart
systemctl status nginx
cat /etc/nginx/sites-available/terminal-ai # view config
journalctl -u nginx -n 50 # nginx system logssudo -u postgres psql terminalai # open DB shell
\dt # list tables
\d ssh_connections # describe table
SELECT * FROM ai_settings; # check AI config
SELECT id, name, host FROM ssh_connections;
TRUNCATE chat_messages; # clear chat history
\q # quit# Test piper directly
echo "Hello world." | piper \
--model /opt/piper/en_US-lessac-medium.onnx \
--output_file /tmp/test.wav
ls -lh /tmp/test.wav # should be >1 KB
# Test TTS API endpoint
curl -s -X POST http://localhost:80/api/tts \
-H "Content-Type: application/json" \
-d '{"text":"Voice is working."}' \
--output /tmp/api_test.wav
ls -lh /tmp/api_test.wav
# Check piper binary location
which piper
echo $PIPER_BINARY # should be set in .env / PM2 envIf Piper is not installed, the TTS route falls back to espeak-ng (robotic voice).
Install espeak-ng as a stopgap: sudo apt install espeak-ng
The SSH session runs over a single WebSocket at /api/ssh/ws.
Message types the client sends:
| type | payload | description |
|---|---|---|
connect |
{ connectionId: number } |
Open SSH session using saved creds |
data |
{ data: string } |
Keystrokes / terminal input |
resize |
{ cols, rows } |
Terminal resize event |
disconnect |
— | Close session |
ping |
— | Keepalive (server ignores) |
Message types the server sends:
| type | payload | description |
|---|---|---|
data |
{ data: string } |
Terminal output |
status |
{ data: "connected" | "disconnected" } |
Session state change |
error |
{ data: string } |
Error message |
The frontend reconnects automatically on unexpected disconnects:
| Attempt | Delay |
|---|---|
| 1 | 2 s |
| 2 | 4 s |
| 3 | 8 s |
| 4 | 16 s |
| 5 | 30 s |
After 5 failures it stops and prints a message to reconnect manually.
- Clicking Disconnect suppresses reconnect permanently (until next manual connect).
- Switching to another app on mobile triggers an immediate reconnect via the
visibilitychangeevent when you return. - A keepalive ping is sent every 20 seconds to prevent Cloudflare / nginx from timing out idle connections.
Settings are stored in the ai_settings DB table and editable in-app via the settings panel.
| Setting | Description |
|---|---|
| Endpoint URL | Any OpenAI-compatible base URL (OpenAI, Ollama, LM Studio, etc.) |
| Model name | e.g. gpt-4o, llama3, mistral |
| API key | Stored encrypted-at-rest in PostgreSQL |
| System prompt | Custom instructions prepended to every conversation |
| GitHub token | Personal access token for memory persistence |
| GitHub repo | owner/repo — memory and session logs go here |
memory.md — persistent facts the AI remembers across sessions
sessions/
2026-05-03T....md — auto-saved session summaries
The AI auto-writes [MEMORY: ...] tags in its responses; these are extracted and appended to memory.md on GitHub every 5 assistant turns.
# Check all services are up
pm2 list
systemctl status nginx
# Check ports are listening
ss -tlnp | grep -E '3001|80|443'
# Test API directly (bypasses nginx)
curl http://localhost:3001/api/healthz
# Test through nginx
curl http://localhost:80/api/healthz# Check nginx has the WebSocket upgrade block
grep -A5 'proxy_pass.*3001' /etc/nginx/sites-available/terminal-ai
# Required nginx directives:
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# Check Cloudflare → WAF may block WebSocket — set tunnel to No TLS Verify
# and ensure WebSockets are enabled in Cloudflare Network tabThis is a Cloudflare WAF rule. Workaround — create connections directly on the server:
curl -s -X POST http://localhost:80/api/ssh-connections \
-H "Content-Type: application/json" \
-d '{"name":"My Server","host":"1.2.3.4","port":22,"username":"ubuntu","authType":"password","password":"SECRET"}'- Check piper is installed:
which piper && piper --version - Check model file exists:
ls -lh /opt/piper/*.onnx - Check env vars:
pm2 env terminal-ai-api | grep PIPER - Tail logs:
pm2 logs terminal-ai-api --lines 50 - Test fallback:
sudo apt install espeak-ng && espeak-ng "test" --stdout > /tmp/e.wav
# Check settings are saved
sudo -u postgres psql terminalai -c "SELECT endpoint_url, model_name, left(api_key,4) FROM ai_settings;"
# Test the endpoint directly
curl -s https://api.openai.com/v1/models \
-H "Authorization: Bearer YOUR_KEY" | jq '.data[0].id'
# Check API server logs for LLM errors
pm2 logs terminal-ai-api --lines 100 | grep -i errorcd /opt/terminal-ai
pnpm --filter @workspace/db run migrate
pm2 reload terminal-ai-api# Force rebuild frontend
cd /opt/terminal-ai
NODE_ENV=production PORT=1 BASE_PATH=/ \
pnpm --filter @workspace/terminal-ai run build
# Check nginx is serving the right dist folder
grep root /etc/nginx/sites-available/terminal-aipm2 logs terminal-ai-api --lines 50 --err
# Common causes:
# - DATABASE_URL not set or wrong password
# - Port 3001 already in use: lsof -i :3001
# - Missing .env file: ls -la /opt/terminal-ai/.env| File | What to edit here |
|---|---|
artifacts/terminal-ai/src/pages/main.tsx |
All frontend logic: SSH, chat, voice, agent, reconnect |
artifacts/terminal-ai/src/components/file-editor.tsx |
In-browser SFTP file editor |
artifacts/terminal-ai/src/components/memory-panel.tsx |
GitHub memory viewer/editor UI |
artifacts/api-server/src/routes/chat.ts |
AI chat, agent mode, memory injection |
artifacts/api-server/src/routes/tts.ts |
Text-to-speech (Piper / espeak-ng) |
artifacts/api-server/src/routes/sftp.ts |
SFTP file browser API |
artifacts/api-server/src/routes/ssh-connections.ts |
Saved SSH connection CRUD |
artifacts/api-server/src/routes/ai-settings.ts |
LLM config API |
artifacts/api-server/src/ssh-ws.ts |
WebSocket SSH bridge |
artifacts/api-server/src/services/github.ts |
GitHub read/write for memory |
lib/db/src/schema/index.ts |
Database table definitions |
lib/api-spec/ |
OpenAPI spec (source of truth — edit before adding routes) |
install.sh |
Fresh server install script |
update.sh |
Update deployed server |
- If it's a new API endpoint: add to
lib/api-spec/first, then run codegen - Add route handler in
artifacts/api-server/src/routes/ - Register route in
artifacts/api-server/src/routes/index.ts - If it needs DB storage: add table to
lib/db/src/schema/index.ts, generate + run migration - Add UI in
artifacts/terminal-ai/src/pages/main.tsxor a new component - Use generated React Query hooks from
@workspace/api-client-reactfor data fetching - Test locally in Replit preview, then run
update.shon server - Do not use
console.login server code — usereq.login routes,loggerelsewhere